# Compose Unstyled 2.10.0 --- --- --- title: Installation description: Learn how to use Compose Unstyled in a new or existing projects. --- ## Quick start Compose Unstyled is distributed via Maven Central, the most trusted source of sharing Kotlin packages. Ensure you have it enabled in your repository sources first: ```kotlin title="settings.gradle.kts" dependencyResolutionManagement { repositories { mavenCentral() } } ``` ```kotlin tabbed // tab: Jetpack Compose android { kotlinOptions { jvmTarget = "17" } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } } dependencies { implementation("com.composables:composeunstyled:2.10.0") } // tab: Compose Multiplatform kotlin { androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } sourceSets { commonMain.dependencies { implementation("com.composables:composeunstyled:2.10.0") } } } ``` ## Modules Compose Unstyled is modular. Add focused modules when you do not need the full dependency. | If you need | Add | | --- | --- | | All components and theming APIs | `composeunstyled` | | Every unstyled component | `composeunstyled-primitives` | | Theming APIs | `composeunstyled-theming` | | Specific components, such as Button | `composeunstyled-button` | | Opinionated themes per platform | `composeunstyled-platformtheme` | Replace `composeunstyled` in Quick Start with the dependencies that fit your app. ```kotlin implementation("com.composables:composeunstyled-button:2.10.0") implementation("com.composables:composeunstyled-text-field:2.10.0") implementation("com.composables:composeunstyled-theming:2.10.0") ``` --- --- title: About description: Learn what Compose Unstyled is and why it exists. --- ## The problem with Material Jetpack Compose and Compose Multiplatform are excellent UI toolkits for building high-quality, modern, complex apps. However, the default design system is Google's Material Design. The 'Material look' feels out of place outside of Android. If you are building for desktop or web, using Material will make your app feel awkward. Material is focused on touch devices with big touch targets. Even when building for Android, there is a big chance that you will need components that look slightly (or completely) different from Material's. Material Compose, Google's implementation of Material Design, has little to no room for customizations. You could rebuild every component from scratch if Material does not cover your needs. But who has time for that when a project is running? Building great, accessible components that feel great for both touch and keyboard can be a time sink. So instead of spending days on this work, we are providing you with the building blocks you need. This allows you to put together a high-quality, accessible design system. All you have to do is bring the styling. ## Key Features ### Fully unstyled Components come with zero styling. They render nothing on the screen by design and make zero design choices for you. If there is something you cannot style, that is considered a bug (kindly file an issue). ### Fully themable Define your own [theme tokens, defaults, and color schemes](https://composeunstyled.com/docs/theming/custom-themes.md). Compose Unstyled provides the theming APIs; your design system decides the values. ### Fully accessible Components are fully accessible and support keyboard navigation out of the box. Semantics implementation is based on the [ARIA spec](https://www.w3.org/WAI/ARIA/apg/patterns/). **Note:** Some Compose Multiplatform targets are more mature than others. All components' accessibility semantics have been tested using Android's TalkBack. ### Developer Experience Components have a simple API. They behave exactly the same on every platform and do not come with platform-specific limitations, such as Android's dialogs fixed sizing. Compose Unstyled is modular. Use focused modules for the APIs you need, or install the common component bundle. For every component, we provide detailed documentation along with detailed code samples for common use cases. There is no lock-in. If you need to modify a component, copy-paste the code into your project and do any modifications you need. Each component is self-contained in its own single Kotlin file. Components are also truly multiplatform. There is no mention of specific platforms on the public API. Platform-specific features, such as styling system bars on Android, are only available to specific targets, which is also documented. ## Frequently Asked Questions ### Is this a component library? No. Components in Unstyled are meant to be used as building blocks to build your own components with your own styling, without having to worry about the complex stuff such as UX details, accessibility and keyboard navigation. In other words **it's how you build your own component library**. ### Is this based off Material Compose? No, all components are written from scratch. A few components do reuse source code from Material compose for behavior purposes and they do not bring any styling. ### Can I use this together with Material Compose or other design systems? Yes. Many people use Unstyled because they prefer the simpler API and customization options of the [ModalBottomSheet](https://composeunstyled.com/docs/components.mdmodal-bottom-sheet/) component than the Material one. --- --- title: Custom Themes seoTitle: Create custom Jetpack Compose Themes description: Learn how to create fully custom themes and how to use them to maintain consistent styling in your Jetpack Compose apps. --- ## Installation Include the Theming module in your app's dependencies: ```kotlin implementation("com.composables:composeunstyled-theming:2.10.0") ``` ## Create a theme To create a theme, use the `buildThemeV2 { }` function. > [!NOTE] > `buildTheme {}` also exists for compatibility with existing themes. `buildThemeV2 {}` adds color-scheme support and is recommended for new themes. ```kotlin val MyTheme = buildThemeV2 { name = "MyTheme" } ``` It returns a theme that you can invoke as a composable to wrap your app with: ```kotlin @Composable fun App() { MyTheme { Box(Modifier.fillMaxSize()) { BasicText("My awesome app") } } } ``` To define light, dark, or custom variants of this theme, see [Color Schemes](https://composeunstyled.com/docs/theming/color-schemes.md). The theme makes the values you define available to its content. Content can access those values using the `Theme` object. Those are usually **colors**, **typography**, **shapes** and anything you need to style your apps with. But we haven't defined any, so let's do that next: ## Define theme values Let's define some colors. To do this, let's create a 'colors' **theme property**. Theme Properties hold a `Map` of **theme tokens**. This links the tokens to the actual values. ```kotlin val colors = ThemeProperty("colors") val background = ThemeToken("background") val onBackground = ThemeToken("on_background") val MyTheme = buildThemeV2 { properties[colors] = mapOf( background to Color(0xFFFAFAFA), onBackground to Color(0XFF0C0A09), ) } ``` Compose Unstyled does not force the structure of your themes and does not come with default styling options that you will end up removing afterwards. You can create any kind of properties you need that fit your design needs. > Even though `buildThemeV2` is not a `@Composable` function, the scope it provides for defining your properties is. This > is handy for when you need to prepare properties asynchronously without blocking the UI thread (such as loading fonts) > and creating [dynamic themes](https://composeunstyled.com/docs/theming/dynamic-themes.md). ## Reading theme values We can now style our app using the `Theme` object to access the values for each token: ```kotlin @Composable fun App() { MyTheme { Box(Modifier.fillMaxSize().background(Theme[colors][background])) { BasicText("My awesome app", style = TextStyle(color = Theme[colors][onBackground])) } } } ``` To set defaults for theme content or override values for a subtree, see [Theme Values](https://composeunstyled.com/docs/theming/theme-values.md). ## Debugging your theme Unstyled will throw an exception when you try to access a token that is not present in the current theme. To make it simpler to debug such scenarios, it is highly recommended to name your themes when you create them. By doing so, Unstyled will provide descriptive error messages when you try to access a token that does not exist during runtime. ```kotlin val LightTheme = buildThemeV2 { name = "LightTheme" } ``` --- --- title: Color Schemes description: Define light, dark, and custom theme variations for your Jetpack Compose app. --- ## Installation ```kotlin implementation("com.composables:composeunstyled-theming:2.10.0") ``` ## Define light and dark schemes Use `buildThemeV2` to define values for each color scheme. When you invoke the theme without choosing a scheme, it automatically selects `ColorScheme.Light` while the system is in light mode and `ColorScheme.Dark` while it is in dark mode. ```kotlin val colors = ThemeProperty("colors") val background = ThemeToken("background") val onBackground = ThemeToken("on_background") val primary = ThemeToken("primary") val AppTheme = buildThemeV2 { properties[colors] = mapOf( primary to Color(0xFF155DFC), ) colorScheme(ColorScheme.Light) { properties[colors] = mapOf( background to Color(0xFFFAFAFA), onBackground to Color(0xFF0C0A09), ) } colorScheme(ColorScheme.Dark) { properties[colors] = mapOf( background to Color(0xFF020617), onBackground to Color(0xFFF1F5F9), ) } } ``` When the system mode changes, the theme recomposes and applies the matching scheme. Values defined outside a `colorScheme` block are used as fallbacks. In this example, both schemes use the shared `primary` value and only override the background values that change. ## Choose a scheme explicitly Pass a `ColorScheme` when applying the theme to choose a scheme yourself. For example, a theme switcher can keep the selected scheme in state and pass it to the theme: ```kotlin @Composable fun App() { var colorScheme by remember { mutableStateOf(ColorScheme.Light) } AppTheme(colorScheme = colorScheme) { Column { UnstyledButton(onClick = { colorScheme = ColorScheme.Light }) { BasicText("Use light theme") } UnstyledButton(onClick = { colorScheme = ColorScheme.Dark }) { BasicText("Use dark theme") } AppContent() } } } ``` You can also define named schemes beyond light and dark: ```kotlin val Sepia = ColorScheme("sepia") val ReaderTheme = buildThemeV2 { colorScheme(Sepia) { properties[colors] = mapOf( background to Color(0xFFF4ECD8), onBackground to Color(0xFF433422), ) } } ``` ## Animate scheme changes By default, scheme changes apply immediately. Set `colorSchemeTransitionSpec` to animate color theme values and the default content color when the active scheme changes. ```kotlin val AppTheme = buildThemeV2 { colorSchemeTransitionSpec = tween(200) // Define color schemes. } ``` ## API Reference ### ColorScheme A named set of theme-property overrides. Use `Light` and `Dark` for system color schemes, or create a custom scheme such as `val Sepia = ColorScheme("sepia")`. | Parameter | Type | Description | |-----------|------|-------------| | `name` | `String` | The name used to identify this color scheme. | ### buildThemeV2 | Parameter | Type | Description | |-----------|------|-------------| | `themeAction` | `ThemeBuilderV2.() -> Unit` | | --- --- title: Theme Values description: Set theme defaults and override values for a subtree of your Compose UI. --- ## Installation ```kotlin implementation("com.composables:composeunstyled-theming:2.10.0") ``` ## Theme defaults A theme provides default values to its content through composition locals. Content chooses whether to read those values. `defaultContentColor` provides `LocalContentColor`. [Text](https://composeunstyled.com/docs/theming/typography.md) uses it when no other text color is set. `defaultTextStyle` provides `LocalTextStyle`. `defaultIndication` provides `LocalIndication`. `defaultTextSelectionColors` provides `LocalTextSelectionColors`. ```kotlin val AppTheme = buildThemeV2 { defaultContentColor = Color(0xFF0C0A09) defaultTextStyle = TextStyle( fontWeight = FontWeight.Medium, fontSize = 16.sp, ) defaultIndication = rememberColoredIndication( hoveredColor = Color.White.copy(alpha = 0.3f), pressedColor = Color.White.copy(alpha = 0.5f), focusedColor = Color.Black.copy(alpha = 0.1f), ) defaultTextSelectionColors = TextSelectionColors( handleColor = Color.Blue, backgroundColor = Color.Blue.copy(alpha = 0.4f), ) } ``` ## Override values locally Use `ProvideContentColor` and `ProvideTextStyle` to replace the theme defaults for a subtree. Content outside that subtree keeps the theme defaults. ```kotlin Column { Text("Standard content") ProvideContentColor(Color.Red.copy(alpha = 0.6f)) { ProvideTextStyle(TextStyle(fontWeight = FontWeight.Bold)) { Text("Important content") } } } ``` ## API Reference ### buildThemeV2 | Parameter | Type | Description | |-----------|------|-------------| | `themeAction` | `ThemeBuilderV2.() -> Unit` | | ### ProvideContentColor | Parameter | Type | Description | |-----------|------|-------------| | `color` | `Color` | | | `content` | `() -> Unit` | | ### ProvideTextStyle | Parameter | Type | Description | |-----------|------|-------------| | `textStyle` | `TextStyle` | | | `content` | `() -> Unit` | | --- --- title: Typography description: Set default text styles in your theme and override them where your Compose UI needs them. --- ## Installation ```kotlin implementation("com.composables:composeunstyled-theming:2.10.0") ``` ## Set default typography in your theme Set `defaultTextStyle` when you create your theme. Every `Text()` inside the theme uses this style by default when you do not pass a `style`. ```kotlin expandable title="TypographyDefaultTextStyleDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/typography/TypographyDefaultTextStyleDemo.kt" import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.composeunstyled.Text import com.composeunstyled.theme.buildThemeV2 private val TypographyTheme = buildThemeV2 { defaultTextStyle = TextStyle( fontStyle = FontStyle.Italic, fontWeight = FontWeight.Light, fontSize = 20.sp, ) } @Preview @Composable fun TypographyDefaultTextStyleDemo() { TypographyTheme { Column( modifier = Modifier .fillMaxSize() .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), ) { Text("This text uses the Theme's typography") BasicText("This text doesn't") } } } ``` ## Define and apply typography tokens Use named tokens for styles that appear in more than one place. Tokens give each role in your type scale one source of truth. ```kotlin val typography = ThemeProperty("typography") val title = ThemeToken("title") val body = ThemeToken("body") val AppTheme = buildThemeV2 { properties[typography] = mapOf( title to TextStyle( fontSize = 24.sp, fontWeight = FontWeight.Bold, ), body to TextStyle( fontSize = 16.sp, lineHeight = 24.sp, ), ) } @Composable fun Article() { AppTheme { Column { Text( text = "Page title", style = Theme[typography][title], ) Text( text = "The body text uses its own token.", style = Theme[typography][body], ) } } } ``` ## Override typography locally Pass styling properties to `Text()` when only one element changes. Use `ProvideTextStyle` to update the inherited style for a subtree. Text outside the subtree keeps its current style. ```kotlin Column { Text("Standard content") Text( text = "A bold status message", fontWeight = FontWeight.Bold, ) ProvideTextStyle( LocalTextStyle.current.copy( fontWeight = FontWeight.Bold, ), ) { Text("Important content") Text("This also uses the local style") } } ``` For other theme defaults and local overrides, see [Theme Values](https://composeunstyled.com/docs/theming/theme-values.md). ## API Reference ### Text | Parameter | Type | Description | |-----------|------|-------------| | `text` | `String` | The text to display. | | `modifier` | `Modifier` | The `Modifier` for the text. | | `style` | `TextStyle` | The style to apply to the text. | | `textAlign` | `TextAlign` | The alignment of the text. | | `lineHeight` | `TextUnit` | The height of the lines. | | `fontSize` | `TextUnit` | The size of the font. | | `letterSpacing` | `TextUnit` | The spacing between letters. | | `fontWeight` | `FontWeight?` | The weight of the font. | | `color` | `Color` | The color of the text. | | `fontFamily` | `FontFamily?` | The family of the font. | | `textDecoration` | `TextDecoration?` | | | `singleLine` | `Boolean` | Whether the text is single line. | | `minLines` | `Int` | Minimum number of lines to display. | | `maxLines` | `Int` | Maximum number of lines to display. | | `onTextLayout` | `((TextLayoutResult) -> Unit)?` | | | `overflow` | `TextOverflow` | How visual overflow should be handled. | | `autoSize` | `TextAutoSize?` | | | `text` | `AnnotatedString` | | --- --- title: Use your Android XML themes in Jetpack Compose description: A step-by-step guide on using your Android XML themes in Jetpack Compose, using Compose Unstyled. social_image: /og_xml_themes.png --- Use this guide when migrating an Android app that still gets design values from XML themes. It lets you avoid maintaining XML and Jetpack Compose theme values separately during the migration. This guide teaches you how to setup your Compose Unstyled theme using your Android XML theme, and use its values in your composables. For the following guide, we will use this typical theme as a reference: ```xml expandable ``` ## Create your Compose theme First off, let's create a Compose theme. It will be 'blank' for now. In the next steps it will be used as the bridge between XML and Compose. Compose Unstyled comes with a theme builder function called `buildThemeV2 {}`. It returns a theme that you can invoke as a composable to wrap your application content. If you are coming from Material Compose, the result of `buildThemeV2 {}` works the same way as Material's [ `MaterialTheme {}`](https://composables.com/docs/androidx.compose.material3/material3/components/MaterialTheme) function. Let's create a blank theme and use it to wrap the contents of our app: ```kotlin expandable import com.composeunstyled.UnstyledButton import androidx.compose.foundation.text.BasicText import com.composeunstyled.theme.buildThemeV2 val AppTheme = buildThemeV2 { } @Composable fun App() { AppTheme { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { BasicText("Hello Styled World!") UnstyledButton( onClick = { }, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), shape = RoundedCornerShape(100) ) { BasicText("Click Me") } } } } ``` ![Android XML themed app before connecting colors](/composeunstyled-v2-assets/xml-theme-guide/step_0.png) This example applies the theme but has not connected any XML values yet. The following sections map XML values to theme tokens, which your components can then read with `Theme`. ## Use your XML colors in Compose Now let's connect the XML world to the Jetpack Compose world. Compose Unstyled comes with a `Theme` object, which is how you can reference values from the current theme. This is similar to Material's `MaterialTheme` object, but in our case it's way more flexible. Let's create a **colors** `ThemeProperty` and put some color `ThemeTokens` to it. We will use these tokens to populate our theme and style our app: ```kotlin expandable val colors = ThemeProperty("colors") val background = ThemeToken("background") val onBackground = ThemeToken("onBackground") val primary = ThemeToken("primary") val onPrimary = ThemeToken("onPrimary") ``` We can now use them in our theme function to read the values of our XML theme. Compose Unstyled comes with `resolveThemeX()` composable functions so that you can read your XML theme values: ```kotlin expandable val AppTheme = buildThemeV2 { // get a reference to the calling (themed) context val context = LocalContext.current // map your XML colors to Compose properties[colors] = mapOf( background to resolveThemeColor(context, R.attr.color_background), onBackground to resolveThemeColor(context, R.attr.color_onBackground), primary to resolveThemeColor(context, R.attr.color_primary), onPrimary to resolveThemeColor(context, R.attr.color_onPrimary), ) } ``` > **Note:** Compose Unstyled does not inflate any XML themes for you. The `resolveThemeX()` functions map the given > context's theme attributes > to Compose's. The `LocalContext` references the context from which you will call `AppTheme` from. For example, if you > call it from your Activity's `setContent {}` function, it will inherit the `android:theme` of your _AndroidManifest.xml_ > file. We can now use our XML theme colors directly in Compose. To access them, use the `Theme` object like this: ```kotlin expandable @Composable fun App() { AppTheme { Column( modifier = Modifier .fillMaxSize() .background(Theme[colors][background]), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { ProvideContentColor(Theme[colors][onBackground]) { BasicText("Hello Styled World!") UnstyledButton( onClick = {}, backgroundColor = Theme[colors][primary], contentColor = Theme[colors][onPrimary], contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), shape = RoundedCornerShape(100) ) { BasicText("Click Me") } } } } } ``` ![Android XML themed app with colors connected](/composeunstyled-v2-assets/xml-theme-guide/step_1.png) Brief explanation of the above code: - `Theme[colors][background]` returns the `background` token of the `colors` property. Similarly for `onBackground`, `primary` and `onPrimary`. - The `ProvideContentColor()` function forwards the given `Color` to its children to render their contents with. - The `Text` composable inherits the content color passed from the `ProvideContentColor` and renders its text using the `onBackground` color of our theme. - We want our button to use the primary/onPrimary combo of the theme, so we use its `backgroundColor` and `contentColor` properties. That's it. Now whenever you update your colors in your XML theme, the changes will be reflected in your composables. ## Use your XML dimens in Compose Let's create some theme tokens for our spacing theme attributes, like we did for our colors: ```kotlin expandable val spacing = ThemeProperty("spacing") val small = ThemeToken("small") val medium = ThemeToken("medium") val large = ThemeToken("large") ``` and now let's map them to our theme: ```kotlin expandable val AppTheme = buildThemeV2 { // get a reference to the calling (themed) context val context = LocalContext.current // map your XML colors to Compose properties[colors] = mapOf( background to resolveThemeColor(context, R.attr.color_background), onBackground to resolveThemeColor(context, R.attr.color_onBackground), primary to resolveThemeColor(context, R.attr.color_primary), onPrimary to resolveThemeColor(context, R.attr.color_onPrimary), ) // map your XML dimens to Compose properties[spacing] = mapOf( small to resolveThemeDp(context, R.attr.spacing_small), medium to resolveThemeDp(context, R.attr.spacing_medium), large to resolveThemeDp(context, R.attr.spacing_large), ) } ``` We can now use our spacing inside our app, using `Theme[spacing][small]`, `Theme[spacing][medium]` and `Theme[spacing][large]`. For our example let's put some spacing between our elements using a `Spacer`: ```kotlin expandable @Composable fun App() { AppTheme { Column( modifier = Modifier .fillMaxSize() .background(Theme[colors][background]), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { ProvideContentColor(Theme[colors][onBackground]) { BasicText("Hello Styled World!") Spacer(Modifier.height(Theme[spacing][large])) UnstyledButton( onClick = {}, backgroundColor = Theme[colors][primary], contentColor = Theme[colors][onPrimary], contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), shape = RoundedCornerShape(100) ) { BasicText("Click Me") } } } } } ``` ![Android XML themed app with custom colors](/composeunstyled-v2-assets/xml-theme-guide/step_2.png) ## Use your XML typography in Compose Let's create theme tokens for our text appearance attributes: ```kotlin expandable val typography = ThemeProperty("typography") val body = ThemeToken("body") ``` Now we can map our XML text appearance to our theme tokens using `resolveThemeTextAppearance`: ```kotlin expandable val AppTheme = buildThemeV2 { // get a reference to the calling (themed) context val context = LocalContext.current // map your XML colors to Compose properties[colors] = mapOf( background to resolveThemeColor(context, R.attr.color_background), onBackground to resolveThemeColor(context, R.attr.color_onBackground), primary to resolveThemeColor(context, R.attr.color_primary), onPrimary to resolveThemeColor(context, R.attr.color_onPrimary), ) // map your XML dimens to Compose properties[spacing] = mapOf( small to resolveThemeDp(context, R.attr.spacing_small), medium to resolveThemeDp(context, R.attr.spacing_medium), large to resolveThemeDp(context, R.attr.spacing_large), ) // map your XML typography to Compose properties[textStyles] = mapOf( body to resolveThemeTextAppearance(context, R.attr.textStyle_body), ) } ``` Now you can use your XML typography in your composables using the new tokens and the `ProvideTextStyle` composable: ```kotlin expandable @Composable fun App() { AppTheme { Column( modifier = Modifier .fillMaxSize() .background(Theme[colors][background]), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { ProvideTextStyle(Theme[textStyles][body]) { ProvideContentColor(Theme[colors][onBackground]) { BasicText("Hello Styled World!") Spacer(Modifier.height(Theme[spacing][large])) UnstyledButton( onClick = {}, backgroundColor = Theme[colors][primary], contentColor = Theme[colors][onPrimary], contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), shape = RoundedCornerShape(100) ) { BasicText("Click Me") } } } } } } ``` ![Android XML themed app with typography connected](/composeunstyled-v2-assets/xml-theme-guide/step_3.png) The `resolveThemeTextAppearance` function automatically resolves: - Font size (`android:textSize`) - Font family (`android:fontFamily`) including custom fonts - Font weight and style (`android:textStyle`) - Text color (`android:textColor`) - Text shadows (`android:shadowColor`, `android:shadowDx`, `android:shadowDy`, `android:shadowRadius`) ## Use the Material Ripple effect in Compose The Material ripple is a signature of Android apps, and we highly recommend using it in your apps for that polished touch effect. For this, we provide a Compose Ripple Indication library: ```kotlin title="app/build.gradle.kts" implementation("com.composables:ripple-indication:1.0.0") ``` This introduces the `rememberRippleIndication()` function, that we can use in our compose theme: ```kotlin expandable val AppTheme = buildThemeV2 { // get a reference to the calling (themed) context val context = LocalContext.current // map your XML colors to Compose val primary = resolveThemeColor(context, R.attr.color_primary) // create a ripple effect using the primary color defaultIndication = rememberRippleIndication( color = primary ) properties[colors] = mapOf( background to resolveThemeColor(context, R.attr.color_background), onBackground to resolveThemeColor(context, R.attr.color_onBackground), primary to primary, onPrimary to resolveThemeColor(context, R.attr.color_onPrimary), ) // map your XML dimens to Compose properties[spacing] = mapOf( small to resolveThemeDp(context, R.attr.spacing_small), medium to resolveThemeDp(context, R.attr.spacing_medium), large to resolveThemeDp(context, R.attr.spacing_large), ) // map your XML typography to Compose properties[textStyles] = mapOf( body to resolveThemeTextAppearance(context, R.attr.textStyle_body), ) } ``` and rerun the app:
--- ## API Reference ### resolveThemeColor | Parameter | Type | Description | |-----------|------|-------------| | `context` | `Context` | The Android Context to resolve attributes from | | `resId` | `Int` | | ### resolveThemeDp | Parameter | Type | Description | |-----------|------|-------------| | `context` | `Context` | The Android Context to resolve attributes from | | `resId` | `Int` | | ### resolveThemeSp | Parameter | Type | Description | |-----------|------|-------------| | `context` | `Context` | The Android Context to resolve attributes from | | `resId` | `Int` | | ### resolveThemePx | Parameter | Type | Description | |-----------|------|-------------| | `context` | `Context` | The Android Context to resolve attributes from | | `resId` | `Int` | | ### resolveThemeInt | Parameter | Type | Description | |-----------|------|-------------| | `context` | `Context` | The Android Context to resolve attributes from | | `resId` | `Int` | | ### resolveThemeFloat | Parameter | Type | Description | |-----------|------|-------------| | `context` | `Context` | The Android Context to resolve attributes from | | `resId` | `Int` | | ### resolveThemeString | Parameter | Type | Description | |-----------|------|-------------| | `context` | `Context` | The Android Context to resolve attributes from | | `resId` | `Int` | | ### resolveThemeBoolean | Parameter | Type | Description | |-----------|------|-------------| | `context` | `Context` | The Android Context to resolve attributes from | | `resId` | `Int` | | ### resolveThemeTextAppearance | Parameter | Type | Description | |-----------|------|-------------| | `context` | `Context` | The Android Context to resolve attributes from | | `resId` | `Int` | | --- --- title: Dynamic Themes description: Create themes that can change over time. --- ## Overview Themes can react to changing state and loaded resources. For light and dark variants, see [Color Schemes](https://composeunstyled.com/docs/theming/color-schemes.md). Themes built with Unstyled are composable functions and can recompose when one of its properties is updated. ### The `buildThemeV2` function Compose Unstyled's `buildThemeV2` function itself is not a `@Composable` function, but the scope it provides for defining your properties is. This means you can call composable functions, read composition locals, and use effects to load resources without blocking the UI thread. This enables dynamic themes, which will recompose once their values change. ## Loading resources asynchronously Since the `buildThemeV2` scope is composable, you can load resources that might not be immediately available. For example, loading custom fonts from disk or fetching theme data from a network source: ```kotlin expandable val typography = ThemeProperty("typography") val body = ThemeToken("body") val AsyncTheme = buildThemeV2 { var fontFamily by remember { mutableStateOf(null) } LaunchedEffect(Unit) { fontFamily = withContext(Dispatchers.IO) { loadCustomFontFromDisk() } } properties[typography] = mapOf( body to (fontFamily ?: FontFamily.Default) ) } ``` The theme will initially use the default font, then automatically update once the custom font loads. --- --- title: Platform Themes description: Native look and feel on every platform with one line of code. Platform Themes set beautiful styling defaults based on the platform your app is running on. --- Platform Themes are a prebuilt alternative to defining a theme yourself. They provide platform-oriented defaults; use [Custom Themes](https://composeunstyled.com/docs/theming/custom-themes.md) when your design system owns its values. ## Installation Include the Platform Theme module in your app's dependencies: ```kotlin implementation("com.composables:composeunstyled-platformtheme:2.10.0") ``` ## Basic usage Use the `buildPlatformTheme` function to create your theme. Then wrap your app with the new theme and you are all set. ```kotlin expandable title="PlatformThemeDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/platformtheme/PlatformThemeDemo.kt" @file:Suppress("ktlint:standard:max-line-length") package com.composeunstyled.demo.platformtheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.Stack import com.composeunstyled.StackOrientation import com.composeunstyled.Text import com.composeunstyled.platformtheme.EmojiVariant import com.composeunstyled.platformtheme.SpokenLanguage import com.composeunstyled.platformtheme.WebFontOptions import com.composeunstyled.platformtheme.buildPlatformTheme import com.composeunstyled.platformtheme.heading1 import com.composeunstyled.platformtheme.heading2 import com.composeunstyled.platformtheme.heading3 import com.composeunstyled.platformtheme.heading4 import com.composeunstyled.platformtheme.heading5 import com.composeunstyled.platformtheme.heading6 import com.composeunstyled.platformtheme.heading7 import com.composeunstyled.platformtheme.heading8 import com.composeunstyled.platformtheme.heading9 import com.composeunstyled.platformtheme.text1 import com.composeunstyled.platformtheme.text2 import com.composeunstyled.platformtheme.text3 import com.composeunstyled.platformtheme.text4 import com.composeunstyled.platformtheme.text5 import com.composeunstyled.platformtheme.text6 import com.composeunstyled.platformtheme.text7 import com.composeunstyled.platformtheme.text8 import com.composeunstyled.platformtheme.text9 import com.composeunstyled.platformtheme.textStyles import com.composeunstyled.theme.Theme private val PlatformTheme = buildPlatformTheme( webFontOptions = WebFontOptions( supportedLanguages = listOf( SpokenLanguage.Korean, SpokenLanguage.Japanese, SpokenLanguage.ChineseSimplified, ), emojiVariant = EmojiVariant.Colored, ), ) @Preview @Composable fun PlatformThemeDemo() { PlatformTheme { Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(32.dp), ) { TypographyDemo() TextStylesDemo() } } } @Composable fun TypographyDemo() { Text("Typography", style = Theme[textStyles][text9]) Text( "The quick brown fox jumps over the lazy dog 😊🦊😘", style = Theme[textStyles][heading9], ) Text("The quick brown fox jumps over the lazy dog 😊🦊😘", style = Theme[textStyles][text9]) Text("Multilanguage", style = Theme[textStyles][text9]) Text("Greek: Η γρήγορη καφέ αλεπού πηδά πάνω από το τεμπέλικο σκυλί") Text("Korean: 빠른 갈색 여우가 게으른 개를 뛰어넘습니다") Text( "Japanese: あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン", ) Text("Chinese Simplified: 敏捷的棕色狐狸跳过懒狗") Text("Chinese Traditional: 敏捷的棕色狐狸跳過懶狗") } @Composable private fun TextStylesDemo() { Column( modifier = Modifier.fillMaxWidth().widthIn(max = 1200.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { val isWide = LocalWindowInfo.current.containerDpSize.width >= 600.dp val orientation = if (isWide) StackOrientation.Horizontal else StackOrientation.Vertical Text("Text Styles", style = Theme[textStyles][text9]) Stack( orientation = orientation, modifier = Modifier.fillMaxWidth(), spacing = 24.dp, ) { val text: String? = null Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(text ?: "Text 9", style = Theme[textStyles][text9]) Text(text ?: "Text 8", style = Theme[textStyles][text8]) Text(text ?: "Text 7", style = Theme[textStyles][text7]) Text(text ?: "Text 6", style = Theme[textStyles][text6]) Text(text ?: "Text 5", style = Theme[textStyles][text5]) Text(text ?: "Text 4", style = Theme[textStyles][text4]) Text(text ?: "Text 3", style = Theme[textStyles][text3]) Text(text ?: "Text 2", style = Theme[textStyles][text2]) Text(text ?: "Text 1", style = Theme[textStyles][text1]) } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(text ?: "Heading 9", style = Theme[textStyles][heading9]) Text(text ?: "Heading 8", style = Theme[textStyles][heading8]) Text(text ?: "Heading 7", style = Theme[textStyles][heading7]) Text(text ?: "Heading 6", style = Theme[textStyles][heading6]) Text(text ?: "Heading 5", style = Theme[textStyles][heading5]) Text(text ?: "Heading 4", style = Theme[textStyles][heading4]) Text(text ?: "Heading 3", style = Theme[textStyles][heading3]) Text(text ?: "Heading 2", style = Theme[textStyles][heading2]) Text(text ?: "Heading 1", style = Theme[textStyles][heading1]) } } } } ``` ```kotlin expandable val AppTheme = buildPlatformTheme( webFontOptions = WebFontOptions( emojiVariant = EmojiVariant.Colored ) ) @Composable fun App() { AppTheme { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { BasicText("🥰✌️🐢🐇", style = Theme[textStyles][text8]) BasicText( text = "Beautiful styling defaults on every platform", style = Theme[textStyles][heading5] ) Row( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { UnstyledButton( onClick = { }, contentPadding = PaddingValues( horizontal = 16.dp, vertical = 8.dp ), shape = Theme[shapes][roundedFull], backgroundColor = Color(0xFF3B82F6), indication = Theme[indications][dimmed], modifier = Modifier .interactiveSize(Theme[interactiveSizes][sizeDefault]) ) { BasicText("Get Started", style = TextStyle(color = Color.White)) } } } } } ``` ## Typography Platform Themes automatically apply text styles to every [`Text`](https://composeunstyled.com/docs/theming/typography.md) and [`TextField`](https://composeunstyled.com/docs/components.mdtextfield/) child. You can also make use of the theme's text style using the `LocalTextStyle` composition local. ### Typography Tokens We provide the `text` and `heading` typography tokens with 9 different sizes each. Each size is either defined in each platform's design guidelines (for example, Material for Android or HIG for Apple) or is a close approximation of one that is defined. ```kotlin expandable title="PlatformThemeDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/platformtheme/PlatformThemeDemo.kt" @file:Suppress("ktlint:standard:max-line-length") package com.composeunstyled.demo.platformtheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.Stack import com.composeunstyled.StackOrientation import com.composeunstyled.Text import com.composeunstyled.platformtheme.EmojiVariant import com.composeunstyled.platformtheme.SpokenLanguage import com.composeunstyled.platformtheme.WebFontOptions import com.composeunstyled.platformtheme.buildPlatformTheme import com.composeunstyled.platformtheme.heading1 import com.composeunstyled.platformtheme.heading2 import com.composeunstyled.platformtheme.heading3 import com.composeunstyled.platformtheme.heading4 import com.composeunstyled.platformtheme.heading5 import com.composeunstyled.platformtheme.heading6 import com.composeunstyled.platformtheme.heading7 import com.composeunstyled.platformtheme.heading8 import com.composeunstyled.platformtheme.heading9 import com.composeunstyled.platformtheme.text1 import com.composeunstyled.platformtheme.text2 import com.composeunstyled.platformtheme.text3 import com.composeunstyled.platformtheme.text4 import com.composeunstyled.platformtheme.text5 import com.composeunstyled.platformtheme.text6 import com.composeunstyled.platformtheme.text7 import com.composeunstyled.platformtheme.text8 import com.composeunstyled.platformtheme.text9 import com.composeunstyled.platformtheme.textStyles import com.composeunstyled.theme.Theme private val PlatformTheme = buildPlatformTheme( webFontOptions = WebFontOptions( supportedLanguages = listOf( SpokenLanguage.Korean, SpokenLanguage.Japanese, SpokenLanguage.ChineseSimplified, ), emojiVariant = EmojiVariant.Colored, ), ) @Preview @Composable fun PlatformThemeDemo() { PlatformTheme { Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(32.dp), ) { TypographyDemo() TextStylesDemo() } } } @Composable fun TypographyDemo() { Text("Typography", style = Theme[textStyles][text9]) Text( "The quick brown fox jumps over the lazy dog 😊🦊😘", style = Theme[textStyles][heading9], ) Text("The quick brown fox jumps over the lazy dog 😊🦊😘", style = Theme[textStyles][text9]) Text("Multilanguage", style = Theme[textStyles][text9]) Text("Greek: Η γρήγορη καφέ αλεπού πηδά πάνω από το τεμπέλικο σκυλί") Text("Korean: 빠른 갈색 여우가 게으른 개를 뛰어넘습니다") Text( "Japanese: あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン", ) Text("Chinese Simplified: 敏捷的棕色狐狸跳过懒狗") Text("Chinese Traditional: 敏捷的棕色狐狸跳過懶狗") } @Composable private fun TextStylesDemo() { Column( modifier = Modifier.fillMaxWidth().widthIn(max = 1200.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { val isWide = LocalWindowInfo.current.containerDpSize.width >= 600.dp val orientation = if (isWide) StackOrientation.Horizontal else StackOrientation.Vertical Text("Text Styles", style = Theme[textStyles][text9]) Stack( orientation = orientation, modifier = Modifier.fillMaxWidth(), spacing = 24.dp, ) { val text: String? = null Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(text ?: "Text 9", style = Theme[textStyles][text9]) Text(text ?: "Text 8", style = Theme[textStyles][text8]) Text(text ?: "Text 7", style = Theme[textStyles][text7]) Text(text ?: "Text 6", style = Theme[textStyles][text6]) Text(text ?: "Text 5", style = Theme[textStyles][text5]) Text(text ?: "Text 4", style = Theme[textStyles][text4]) Text(text ?: "Text 3", style = Theme[textStyles][text3]) Text(text ?: "Text 2", style = Theme[textStyles][text2]) Text(text ?: "Text 1", style = Theme[textStyles][text1]) } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(text ?: "Heading 9", style = Theme[textStyles][heading9]) Text(text ?: "Heading 8", style = Theme[textStyles][heading8]) Text(text ?: "Heading 7", style = Theme[textStyles][heading7]) Text(text ?: "Heading 6", style = Theme[textStyles][heading6]) Text(text ?: "Heading 5", style = Theme[textStyles][heading5]) Text(text ?: "Heading 4", style = Theme[textStyles][heading4]) Text(text ?: "Heading 3", style = Theme[textStyles][heading3]) Text(text ?: "Heading 2", style = Theme[textStyles][heading2]) Text(text ?: "Heading 1", style = Theme[textStyles][heading1]) } } } } ``` This way, you can make sure that the sizing of your app feels cohesive on every platform without sweating the details. By default, scale number `4` is applied when you use the Theme. If you need to render text smaller than that, use a smaller scale. If you need larger text, use a bigger number. | Scale | Android | iOS | Desktop | Web | |------------|---------|------|---------|------| | `1` | 11sp | 12sp | 10sp | 10sp | | `2` | 12sp | 13sp | 11sp | 12sp | | `3` | 14sp | 16sp | 12sp | 14sp | | `4` (base) | 16sp | 17sp | 13sp | 16sp | | `5` | 22sp | 18sp | 14sp | 18sp | | `6` | 24sp | 20sp | 15sp | 20sp | | `7` | 28sp | 22sp | 17sp | 24sp | | `8` | 32sp | 28sp | 22sp | 28sp | | `9` | 36sp | 34sp | 26sp | 35sp | ### Using system fonts Platform Themes automatically apply system fonts on every platform. This way, you get the default typography on every platform without us having to bundle font files and increase the size of your app. The exception to this is the Web platform. Browsers do not currently have access to the computer's installed fonts outside of CSS. Because of this technical limitation, we bundle [Noto Sans](https://fonts.google.com/noto/specimen/Noto+Sans) on Web. Noto Sans is a global font that comes with variations with pretty much every script out there. ## Displaying non-Latin text on Web Use `webFontOptions` while building your Platform Theme to specify the scripts that your app needs. ```kotlin expandable title="PlatformThemeDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/platformtheme/PlatformThemeDemo.kt" @file:Suppress("ktlint:standard:max-line-length") package com.composeunstyled.demo.platformtheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.Stack import com.composeunstyled.StackOrientation import com.composeunstyled.Text import com.composeunstyled.platformtheme.EmojiVariant import com.composeunstyled.platformtheme.SpokenLanguage import com.composeunstyled.platformtheme.WebFontOptions import com.composeunstyled.platformtheme.buildPlatformTheme import com.composeunstyled.platformtheme.heading1 import com.composeunstyled.platformtheme.heading2 import com.composeunstyled.platformtheme.heading3 import com.composeunstyled.platformtheme.heading4 import com.composeunstyled.platformtheme.heading5 import com.composeunstyled.platformtheme.heading6 import com.composeunstyled.platformtheme.heading7 import com.composeunstyled.platformtheme.heading8 import com.composeunstyled.platformtheme.heading9 import com.composeunstyled.platformtheme.text1 import com.composeunstyled.platformtheme.text2 import com.composeunstyled.platformtheme.text3 import com.composeunstyled.platformtheme.text4 import com.composeunstyled.platformtheme.text5 import com.composeunstyled.platformtheme.text6 import com.composeunstyled.platformtheme.text7 import com.composeunstyled.platformtheme.text8 import com.composeunstyled.platformtheme.text9 import com.composeunstyled.platformtheme.textStyles import com.composeunstyled.theme.Theme private val PlatformTheme = buildPlatformTheme( webFontOptions = WebFontOptions( supportedLanguages = listOf( SpokenLanguage.Korean, SpokenLanguage.Japanese, SpokenLanguage.ChineseSimplified, ), emojiVariant = EmojiVariant.Colored, ), ) @Preview @Composable fun PlatformThemeDemo() { PlatformTheme { Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(32.dp), ) { TypographyDemo() TextStylesDemo() } } } @Composable fun TypographyDemo() { Text("Typography", style = Theme[textStyles][text9]) Text( "The quick brown fox jumps over the lazy dog 😊🦊😘", style = Theme[textStyles][heading9], ) Text("The quick brown fox jumps over the lazy dog 😊🦊😘", style = Theme[textStyles][text9]) Text("Multilanguage", style = Theme[textStyles][text9]) Text("Greek: Η γρήγορη καφέ αλεπού πηδά πάνω από το τεμπέλικο σκυλί") Text("Korean: 빠른 갈색 여우가 게으른 개를 뛰어넘습니다") Text( "Japanese: あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン", ) Text("Chinese Simplified: 敏捷的棕色狐狸跳过懒狗") Text("Chinese Traditional: 敏捷的棕色狐狸跳過懶狗") } @Composable private fun TextStylesDemo() { Column( modifier = Modifier.fillMaxWidth().widthIn(max = 1200.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { val isWide = LocalWindowInfo.current.containerDpSize.width >= 600.dp val orientation = if (isWide) StackOrientation.Horizontal else StackOrientation.Vertical Text("Text Styles", style = Theme[textStyles][text9]) Stack( orientation = orientation, modifier = Modifier.fillMaxWidth(), spacing = 24.dp, ) { val text: String? = null Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(text ?: "Text 9", style = Theme[textStyles][text9]) Text(text ?: "Text 8", style = Theme[textStyles][text8]) Text(text ?: "Text 7", style = Theme[textStyles][text7]) Text(text ?: "Text 6", style = Theme[textStyles][text6]) Text(text ?: "Text 5", style = Theme[textStyles][text5]) Text(text ?: "Text 4", style = Theme[textStyles][text4]) Text(text ?: "Text 3", style = Theme[textStyles][text3]) Text(text ?: "Text 2", style = Theme[textStyles][text2]) Text(text ?: "Text 1", style = Theme[textStyles][text1]) } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(text ?: "Heading 9", style = Theme[textStyles][heading9]) Text(text ?: "Heading 8", style = Theme[textStyles][heading8]) Text(text ?: "Heading 7", style = Theme[textStyles][heading7]) Text(text ?: "Heading 6", style = Theme[textStyles][heading6]) Text(text ?: "Heading 5", style = Theme[textStyles][heading5]) Text(text ?: "Heading 4", style = Theme[textStyles][heading4]) Text(text ?: "Heading 3", style = Theme[textStyles][heading3]) Text(text ?: "Heading 2", style = Theme[textStyles][heading2]) Text(text ?: "Heading 1", style = Theme[textStyles][heading1]) } } } } ``` ```kotlin expandable val AppTheme = buildPlatformTheme( webFontOptions = WebFontOptions( supportedLanguages = listOf(SpokenLanguage.Japanese) ) ) @Composable fun App() { AppTheme { BasicText("海賊王に俺はなる", style = Theme[textStyles][heading5]) } } ``` > [!WARNING] > Use this API with caution. Compose Web will cause your app to freeze while big sized fonts are being loaded for the first time. They are then cached by the browser. Only use the scripts that you need to reduce unresponsiveness. We currently support Japanese, Korean, Chinese Traditional and Chinese Simplified. If there is a script you would like us to support, feel free to request it via a GitHub issue. ## Displaying emojis on Web Use `webFontOptions` while building your Platform Theme to specify the emoji variant you would like to use. By default, `Monochrome` is used as it is a good compromise between having emojis and speed: ```kotlin expandable title="PlatformThemeDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/platformtheme/PlatformThemeDemo.kt" @file:Suppress("ktlint:standard:max-line-length") package com.composeunstyled.demo.platformtheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.Stack import com.composeunstyled.StackOrientation import com.composeunstyled.Text import com.composeunstyled.platformtheme.EmojiVariant import com.composeunstyled.platformtheme.SpokenLanguage import com.composeunstyled.platformtheme.WebFontOptions import com.composeunstyled.platformtheme.buildPlatformTheme import com.composeunstyled.platformtheme.heading1 import com.composeunstyled.platformtheme.heading2 import com.composeunstyled.platformtheme.heading3 import com.composeunstyled.platformtheme.heading4 import com.composeunstyled.platformtheme.heading5 import com.composeunstyled.platformtheme.heading6 import com.composeunstyled.platformtheme.heading7 import com.composeunstyled.platformtheme.heading8 import com.composeunstyled.platformtheme.heading9 import com.composeunstyled.platformtheme.text1 import com.composeunstyled.platformtheme.text2 import com.composeunstyled.platformtheme.text3 import com.composeunstyled.platformtheme.text4 import com.composeunstyled.platformtheme.text5 import com.composeunstyled.platformtheme.text6 import com.composeunstyled.platformtheme.text7 import com.composeunstyled.platformtheme.text8 import com.composeunstyled.platformtheme.text9 import com.composeunstyled.platformtheme.textStyles import com.composeunstyled.theme.Theme private val PlatformTheme = buildPlatformTheme( webFontOptions = WebFontOptions( supportedLanguages = listOf( SpokenLanguage.Korean, SpokenLanguage.Japanese, SpokenLanguage.ChineseSimplified, ), emojiVariant = EmojiVariant.Colored, ), ) @Preview @Composable fun PlatformThemeDemo() { PlatformTheme { Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(32.dp), ) { TypographyDemo() TextStylesDemo() } } } @Composable fun TypographyDemo() { Text("Typography", style = Theme[textStyles][text9]) Text( "The quick brown fox jumps over the lazy dog 😊🦊😘", style = Theme[textStyles][heading9], ) Text("The quick brown fox jumps over the lazy dog 😊🦊😘", style = Theme[textStyles][text9]) Text("Multilanguage", style = Theme[textStyles][text9]) Text("Greek: Η γρήγορη καφέ αλεπού πηδά πάνω από το τεμπέλικο σκυλί") Text("Korean: 빠른 갈색 여우가 게으른 개를 뛰어넘습니다") Text( "Japanese: あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン", ) Text("Chinese Simplified: 敏捷的棕色狐狸跳过懒狗") Text("Chinese Traditional: 敏捷的棕色狐狸跳過懶狗") } @Composable private fun TextStylesDemo() { Column( modifier = Modifier.fillMaxWidth().widthIn(max = 1200.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { val isWide = LocalWindowInfo.current.containerDpSize.width >= 600.dp val orientation = if (isWide) StackOrientation.Horizontal else StackOrientation.Vertical Text("Text Styles", style = Theme[textStyles][text9]) Stack( orientation = orientation, modifier = Modifier.fillMaxWidth(), spacing = 24.dp, ) { val text: String? = null Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(text ?: "Text 9", style = Theme[textStyles][text9]) Text(text ?: "Text 8", style = Theme[textStyles][text8]) Text(text ?: "Text 7", style = Theme[textStyles][text7]) Text(text ?: "Text 6", style = Theme[textStyles][text6]) Text(text ?: "Text 5", style = Theme[textStyles][text5]) Text(text ?: "Text 4", style = Theme[textStyles][text4]) Text(text ?: "Text 3", style = Theme[textStyles][text3]) Text(text ?: "Text 2", style = Theme[textStyles][text2]) Text(text ?: "Text 1", style = Theme[textStyles][text1]) } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(text ?: "Heading 9", style = Theme[textStyles][heading9]) Text(text ?: "Heading 8", style = Theme[textStyles][heading8]) Text(text ?: "Heading 7", style = Theme[textStyles][heading7]) Text(text ?: "Heading 6", style = Theme[textStyles][heading6]) Text(text ?: "Heading 5", style = Theme[textStyles][heading5]) Text(text ?: "Heading 4", style = Theme[textStyles][heading4]) Text(text ?: "Heading 3", style = Theme[textStyles][heading3]) Text(text ?: "Heading 2", style = Theme[textStyles][heading2]) Text(text ?: "Heading 1", style = Theme[textStyles][heading1]) } } } } ``` ```kotlin expandable val AppTheme = buildPlatformTheme( webFontOptions = WebFontOptions( emojiVariant = EmojiVariant.Colored ) ) @Composable fun App() { AppTheme { Column(modifier = Modifier.padding(16.dp)) { BasicText("🎉 🚀 ❤️ 🌟 🎨", style = Theme[textStyles][heading8]) } } } ``` ## Indications Platform Themes apply an `indication` to their children according to each platform's look and feel. We provide two Theme tokens: `bright` and `dimmed`. The default indication is `bright`. Use `platformIndication` when a component should ask for platform-native interaction feedback directly: ```kotlin expandable val brightIndication = platformIndication(Color.White.copy(alpha = 0.18f)) val dimmedIndication = platformIndication(Color.Black.copy(alpha = 0.08f)) ``` Compose Unstyled applies the provided color to the platform indication where the platform supports it.

Android

iOS

Desktop

Web

## Interaction sizes Platform Themes provide interaction size tokens that ensure your interactive elements meet accessibility standards on every platform. The sizing comes from each platform's design guidelines, ensuring optimal usability whether users are tapping on a touchscreen or clicking with a mouse. We provide two size tokens: `sizeDefault` and `sizeMinimum`. | Token | Android | iOS | Desktop | Web | |---------------|---------|------|---------|------| | `sizeDefault` | 48dp | 44dp | 28dp | 28dp | | `sizeMinimum` | 32dp | 28dp | 20dp | 20dp | Use the `interactiveSize` modifier to apply these sizes to your interactive elements: ```kotlin expandable UnstyledButton( onClick = { /* ... */ }, modifier = Modifier.interactiveSize(Theme[interactiveSizes][sizeDefault]) ) { BasicText("Click me") } ``` This ensures your buttons, checkboxes, and other interactive elements are always sized appropriately for the platform they're running on. ## Shapes Shape theme tokens are not platform specific, however they are very handy when building apps. | Token | Radius | |----------------|--------| | `roundedNone` | 0dp | | `roundedSmall` | 4dp | | `roundedMedium`| 6dp | | `roundedLarge` | 8dp | | `roundedFull` | 100% | ```kotlin expandable val AppTheme = buildPlatformTheme() AppTheme { Box(modifier = Modifier.size(60.dp).background(Color(0xFF3B82F6), Theme[shapes][roundedNone])) Box(modifier = Modifier.size(60.dp).background(Color(0xFF3B82F6), Theme[shapes][roundedSmall])) Box(modifier = Modifier.size(60.dp).background(Color(0xFF3B82F6), Theme[shapes][roundedMedium])) Box(modifier = Modifier.size(60.dp).background(Color(0xFF3B82F6), Theme[shapes][roundedLarge])) } ``` ```kotlin expandable title="PlatformThemeDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/platformtheme/PlatformThemeDemo.kt" @file:Suppress("ktlint:standard:max-line-length") package com.composeunstyled.demo.platformtheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.Stack import com.composeunstyled.StackOrientation import com.composeunstyled.Text import com.composeunstyled.platformtheme.EmojiVariant import com.composeunstyled.platformtheme.SpokenLanguage import com.composeunstyled.platformtheme.WebFontOptions import com.composeunstyled.platformtheme.buildPlatformTheme import com.composeunstyled.platformtheme.heading1 import com.composeunstyled.platformtheme.heading2 import com.composeunstyled.platformtheme.heading3 import com.composeunstyled.platformtheme.heading4 import com.composeunstyled.platformtheme.heading5 import com.composeunstyled.platformtheme.heading6 import com.composeunstyled.platformtheme.heading7 import com.composeunstyled.platformtheme.heading8 import com.composeunstyled.platformtheme.heading9 import com.composeunstyled.platformtheme.text1 import com.composeunstyled.platformtheme.text2 import com.composeunstyled.platformtheme.text3 import com.composeunstyled.platformtheme.text4 import com.composeunstyled.platformtheme.text5 import com.composeunstyled.platformtheme.text6 import com.composeunstyled.platformtheme.text7 import com.composeunstyled.platformtheme.text8 import com.composeunstyled.platformtheme.text9 import com.composeunstyled.platformtheme.textStyles import com.composeunstyled.theme.Theme private val PlatformTheme = buildPlatformTheme( webFontOptions = WebFontOptions( supportedLanguages = listOf( SpokenLanguage.Korean, SpokenLanguage.Japanese, SpokenLanguage.ChineseSimplified, ), emojiVariant = EmojiVariant.Colored, ), ) @Preview @Composable fun PlatformThemeDemo() { PlatformTheme { Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(32.dp), ) { TypographyDemo() TextStylesDemo() } } } @Composable fun TypographyDemo() { Text("Typography", style = Theme[textStyles][text9]) Text( "The quick brown fox jumps over the lazy dog 😊🦊😘", style = Theme[textStyles][heading9], ) Text("The quick brown fox jumps over the lazy dog 😊🦊😘", style = Theme[textStyles][text9]) Text("Multilanguage", style = Theme[textStyles][text9]) Text("Greek: Η γρήγορη καφέ αλεπού πηδά πάνω από το τεμπέλικο σκυλί") Text("Korean: 빠른 갈색 여우가 게으른 개를 뛰어넘습니다") Text( "Japanese: あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン", ) Text("Chinese Simplified: 敏捷的棕色狐狸跳过懒狗") Text("Chinese Traditional: 敏捷的棕色狐狸跳過懶狗") } @Composable private fun TextStylesDemo() { Column( modifier = Modifier.fillMaxWidth().widthIn(max = 1200.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { val isWide = LocalWindowInfo.current.containerDpSize.width >= 600.dp val orientation = if (isWide) StackOrientation.Horizontal else StackOrientation.Vertical Text("Text Styles", style = Theme[textStyles][text9]) Stack( orientation = orientation, modifier = Modifier.fillMaxWidth(), spacing = 24.dp, ) { val text: String? = null Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(text ?: "Text 9", style = Theme[textStyles][text9]) Text(text ?: "Text 8", style = Theme[textStyles][text8]) Text(text ?: "Text 7", style = Theme[textStyles][text7]) Text(text ?: "Text 6", style = Theme[textStyles][text6]) Text(text ?: "Text 5", style = Theme[textStyles][text5]) Text(text ?: "Text 4", style = Theme[textStyles][text4]) Text(text ?: "Text 3", style = Theme[textStyles][text3]) Text(text ?: "Text 2", style = Theme[textStyles][text2]) Text(text ?: "Text 1", style = Theme[textStyles][text1]) } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(text ?: "Heading 9", style = Theme[textStyles][heading9]) Text(text ?: "Heading 8", style = Theme[textStyles][heading8]) Text(text ?: "Heading 7", style = Theme[textStyles][heading7]) Text(text ?: "Heading 6", style = Theme[textStyles][heading6]) Text(text ?: "Heading 5", style = Theme[textStyles][heading5]) Text(text ?: "Heading 4", style = Theme[textStyles][heading4]) Text(text ?: "Heading 3", style = Theme[textStyles][heading3]) Text(text ?: "Heading 2", style = Theme[textStyles][heading2]) Text(text ?: "Heading 1", style = Theme[textStyles][heading1]) } } } } ``` ## API Reference ### buildPlatformTheme | Parameter | Type | Description | |-----------|------|-------------| | `webFontOptions` | `WebFontOptions` | Options for loading platform fonts on web targets. | | `themeAction` | `ThemeBuilder.() -> Unit` | Theme builder block for overriding or adding theme values. | ### platformIndication | Parameter | Type | Description | |-----------|------|-------------| | `color` | `Color` | Color to apply to the platform indication where the platform supports it. | ### Modifier.interactiveSize | Parameter | Type | Description | |-----------|------|-------------| | `size` | `Dp` | Minimum interactive size to apply to the modifier. | --- --- title: defaultMinimumComponentInteractiveSize description: A modifier that sets the minimum interactive size of a composable based on the current device type and theme configuration. --- > [!WARNING] > `ComponentInteractiveSize` and `defaultComponentInteractiveSize` are deprecated and will be removed in 3.0. If your design system needs a minimum interactive size, implement that policy in your own components. ## Installation ```kotlin implementation("com.composables:composeunstyled-theming:2.10.0") ``` ## Code Examples ### Basic Usage Use the `defaultComponentInteractiveSize` theme property to specify the minimum interaction size for your components. Use `Modifier.minimumInteractiveComponentSize()` when creating your components to set the minimum size: ```kotlin expandable import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import com.composeunstyled.UnstyledButton import androidx.compose.foundation.text.BasicText import com.composeunstyled.minimumInteractiveComponentSize import com.composeunstyled.theme.ComponentInteractiveSize import com.composeunstyled.theme.buildThemeV2 @Composable fun MinimumInteractiveSizeBasicExample() { val Theme = buildThemeV2 { defaultComponentInteractiveSize = ComponentInteractiveSize( size = 48.dp, ) } Theme { UnstyledButton( onClick = { }, backgroundColor = Color(0xFF3B82F6), shape = RoundedCornerShape(50), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.minimumInteractiveComponentSize() ) { BasicText("Click me", style = TextStyle(color = Color.White)) } } } ``` ```kotlin expandable import com.composeunstyled.minimumInteractiveComponentSize import com.composeunstyled.theme.buildThemeV2 import com.composeunstyled.theme.ComponentInteractiveSize ``` ```kotlin expandable val Theme = buildThemeV2 { defaultComponentInteractiveSize = ComponentInteractiveSize( size = 48.dp, ) } @Composable fun MinimumInteractiveSizeExample() { Theme { UnstyledButton(onClick = { }, modifier = Modifier.minimumInteractiveComponentSize()) { BasicText("Click me") } } } ``` ### Responsive Design Use the `touchInteractionSize` parameter to set the minimum interactive size when running on touch devices (such as mobile). Use the `nonTouchInteractionSize` parameter to set the size when running on non-touch devices (such as desktop). ```kotlin expandable import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.composeunstyled.UnstyledButton import androidx.compose.foundation.text.BasicText import com.composeunstyled.minimumInteractiveComponentSize import com.composeunstyled.theme.ComponentInteractiveSize import com.composeunstyled.theme.buildThemeV2 @Composable fun MinimumInteractiveSizeResponsiveExample() { val Theme = buildThemeV2 { defaultComponentInteractiveSize = ComponentInteractiveSize( touchInteractionSize = 48.dp, nonTouchInteractionSize = 32.dp ) } Theme { UnstyledButton( onClick = { }, backgroundColor = Color(0xFF3B82F6), shape = RoundedCornerShape(50), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.minimumInteractiveComponentSize() ) { BasicText("Click me", style = TextStyle(color = Color.White)) } } } ``` ```kotlin expandable import com.composeunstyled.minimumInteractiveComponentSize import com.composeunstyled.theme.buildThemeV2 import com.composeunstyled.theme.ComponentInteractiveSize ``` ```kotlin expandable val Theme = buildThemeV2 { defaultComponentInteractiveSize = ComponentInteractiveSize( touchInteractionSize = 48.dp, nonTouchInteractionSize = 32.dp ) } @Composable fun ResponsiveInteractiveSizeExample() { Theme { // this button will be at least 48x48 on mobile and 32x32 on desktop UnstyledButton(onClick = { }, modifier = Modifier.minimumInteractiveComponentSize()) { BasicText("Click me") } } } ``` --- --- title: Unstyled Components description: Find all available unstyled components in Compose Unstyled. --- ## Installation Add the component modules you use: ```kotlin implementation("com.composables:composeunstyled-button:2.10.0") implementation("com.composables:composeunstyled-checkbox:2.10.0") implementation("com.composables:composeunstyled-text-field:2.10.0") ``` Each component is published as its own module in Compose Unstyled 2.0.0. Use the dependencies from the Installation page to keep each feature module focused on only the APIs it uses. --- ## Components [Overview](https://composeunstyled.com/docs/components.md) [Avatar](https://composeunstyled.com/docs/components.mdavatar/) [Bottom Sheet](https://composeunstyled.com/docs/components.mdbottom-sheet/) [Bottom Sheet (Modal)](https://composeunstyled.com/docs/components.mdmodal-bottom-sheet/) [Button](https://composeunstyled.com/docs/components.mdbutton/) [Checkbox](https://composeunstyled.com/docs/components.mdcheckbox/) [Checkbox (TriState)](https://composeunstyled.com/docs/components.mdtristatecheckbox/) [Dialog](https://composeunstyled.com/docs/components.mddialog/) [Disclosure](https://composeunstyled.com/docs/components.mddisclosure/) [Drawer](https://composeunstyled.com/docs/components.mddrawer/) [Dropdown Menu](https://composeunstyled.com/docs/components.mddropdown-menu/) [Icon](https://composeunstyled.com/docs/components.mdicon/) [Progress Indicator](https://composeunstyled.com/docs/components.mdprogressindicator/) [Radio Group](https://composeunstyled.com/docs/components.mdradiogroup/) [Scrollbars](https://composeunstyled.com/docs/components.mdscrollbars/) [Separators](https://composeunstyled.com/docs/components.mdseparators/) [Slider](https://composeunstyled.com/docs/components.mdslider/) [Tab Group](https://composeunstyled.com/docs/components.mdtabgroup/) [Text Field](https://composeunstyled.com/docs/components.mdtextfield/) [Toggle Switch](https://composeunstyled.com/docs/components.mdtoggleswitch/) [Tooltip](https://composeunstyled.com/docs/components.mdtooltip/) --- ## Styling your components Every component in Compose Unstyled is renderless. They handle all UX pattern logic, internal state, accessibility (according to ARIA standards), and keyboard interactions for you, but they do not render any UI to the screen. This is by design so that you can style your components exactly to your needs. Most of the time, styling is done using `Modifiers` of your choice. However, sometimes this is not enough due to the order of the `Modifier`s affecting the visual outcome. For such cases we provide specific styling parameters. --- --- title: Avatar description: An unstyled avatar component with image, fallback content, and caller-defined shape. --- ```kotlin expandable title="AvatarDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/avatar/AvatarDemo.kt" import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composables.uripainter.rememberUriPainter import com.composeunstyled.Text import com.composeunstyled.UnstyledAvatar import com.composeunstyled.demo.colors import com.composeunstyled.demo.inputBackgroundToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme import kotlin.time.Duration.Companion.milliseconds @Preview @Composable fun AvatarDemo() { Row( modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically, ) { UnstyledAvatar( painter = null, underlay = { Text("CC") }, contentDescription = "@coolcat", modifier = Modifier .size(32.dp) .clip(CircleShape) .border(1.dp, Theme[colors][surfaceToken], CircleShape) .background(Theme[colors][inputBackgroundToken]), contentScale = ContentScale.Crop, ) val painter = rememberUriPainter( "https://images.unsplash.com/photo-1533738363-b7f9aef128ce?q=80&w=1080", crossfade = 50.milliseconds, ) UnstyledAvatar( painter = painter, underlay = { Text("CC") }, contentDescription = "@coolcat", modifier = Modifier .size(48.dp) .clip(CircleShape) .border(1.dp, Theme[colors][surfaceToken], CircleShape) .background(Theme[colors][inputBackgroundToken]), contentScale = ContentScale.Crop, ) UnstyledAvatar( painter = painter, underlay = { Text("CC") }, contentDescription = "@coolcat", modifier = Modifier .size(56.dp) .clip(CircleShape) .border(1.dp, Theme[colors][surfaceToken], CircleShape) .background(Theme[colors][inputBackgroundToken]), contentScale = ContentScale.Crop, ) } } ``` ## Features - Use any kind of painter you want - Fallback content under the image ## Installation ```kotlin implementation("com.composables:composeunstyled-avatar:2.10.0") ``` ## Anatomy ```kotlin val painter = rememberUriPainter( uri = "https://images.unsplash.com/photo-1533738363-b7f9aef128ce?q=80&w=1080", crossfade = 50.milliseconds, ) UnstyledAvatar( painter = painter, contentDescription = "@coolcat", underlay = { BasicText("CC") }, ) ``` ## Concepts - `UnstyledAvatar` represents the entire render avatar. - The `underlay` slot is placed behind the image, so it can provide initials or placeholder content when the `painter` fails to load the image or is `null`. ## Accessibility Pass a `contentDescription` when the avatar identifies a person, account, or brand. Use `null` when the avatar is decorative. ## Code Examples ### Showing initials until an image is available Use the `underlay` parameter to provide fallback content behind the image. This is useful when the image may be missing or still loading. ```kotlin expandable UnstyledAvatar( painter = null, contentDescription = "@coolcat", underlay = { BasicText("CC") }, modifier = Modifier .size(40.dp) .clip(CircleShape), ) ``` ### Cropping profile photos to the avatar bounds Use the `contentScale` parameter to crop the image to the avatar container. ```kotlin expandable val painter = rememberUriPainter( uri = "https://images.unsplash.com/photo-1533738363-b7f9aef128ce?q=80&w=1080", crossfade = 50.milliseconds, ) UnstyledAvatar( painter = painter, contentDescription = "@coolcat", contentScale = ContentScale.Crop, modifier = Modifier .size(40.dp) .clip(CircleShape), ) ``` ## API Reference ### UnstyledAvatar | Parameter | Type | Description | |-----------|------|-------------| | `painter` | `Painter?` | The `Painter` to draw inside the avatar. Pass `null` to show only the fallback content. | | `modifier` | `Modifier` | The `Modifier` applied to the avatar container. | | `contentDescription` | `String?` | Accessibility text describing what the avatar represents. | | `underlay` | `(() -> Unit)?` | Composable content placed behind the image, such as initials or a placeholder. | | `contentScale` | `ContentScale` | Controls how the image is scaled inside the avatar bounds. | --- --- title: Bottom Sheet description: A draggable bottom sheet with custom detents. --- > **Recommendation:** For new interfaces, use [Drawer](https://composeunstyled.com/docs/components.mddrawer/) instead. It is a more flexible abstraction and the recommended API moving forward. ```kotlin expandable title="BottomSheetDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/bottomsheet/BottomSheetDemo.kt" import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.DragIndication import com.composeunstyled.Sheet import com.composeunstyled.SheetDetent import com.composeunstyled.SheetDetent.Companion.FullyExpanded import com.composeunstyled.SheetDetent.Companion.Hidden import com.composeunstyled.Text import com.composeunstyled.UnstyledBottomSheet import com.composeunstyled.UnstyledButton import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.surfaceToken import com.composeunstyled.rememberBottomSheetState import com.composeunstyled.theme.Theme @Preview @Composable fun BottomSheetDemo() { val Peek = SheetDetent("peek") { containerHeight, _ -> containerHeight * 0.6f } val sheetState = rememberBottomSheetState( initialDetent = Peek, detents = listOf(Hidden, Peek, FullyExpanded), ) Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { UnstyledButton( onClick = { sheetState.targetDetent = Peek }, modifier = Modifier .heightIn(32.dp) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), contentPadding = PaddingValues(horizontal = 10.dp), indication = LocalIndication.current, ) { Text("Show bottom sheet") } UnstyledBottomSheet( state = sheetState, modifier = Modifier.fillMaxSize().padding(top = 12.dp), ) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { Sheet( modifier = Modifier .widthIn(max = 640.dp) .fillMaxWidth() .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), ) { Box(Modifier.fillMaxWidth().height(1000.dp)) { DragIndication( modifier = Modifier .align(Alignment.TopCenter) .padding(top = 22.dp) .background(Theme[colors][borderToken]) .size(32.dp, 4.dp), indication = LocalIndication.current, ) } } } } } } ``` ## Features - Custom detents - Soft-keyboard support - Dynamic content sizing - Scrollable content without fixed height ## Installation ```kotlin implementation("com.composables:composeunstyled-bottom-sheet:2.10.0") ``` ## Anatomy ```kotlin val sheetState = rememberBottomSheetState( initialDetent = SheetDetent.Hidden, ) UnstyledBottomSheet(state = sheetState) { Sheet { DragIndication() } } ``` ## Concepts - `SheetDetent` defines a height where the sheet can rest. - `UnstyledBottomSheet` represents the draggable container the `Sheet` is dragged in. - `Sheet` is the rendered bit of the sheet. - `DragIndication` adds an interactive handle for expand, collapse, and dismiss actions. ## Accessibility Use the `DragIndication` component when your sheet can move between multiple detents. It provides semantic expand, collapse, and dismiss actions so users can control the sheet without dragging. ## Code Examples ### Showing and hiding the sheet Use the `targetDetent` property to animate the sheet to a new detent: ```kotlin expandable val sheetState = rememberBottomSheetState( initialDetent = SheetDetent.Hidden, ) BasicText( text = "Show sheet", modifier = Modifier.clickable { sheetState.targetDetent = SheetDetent.FullyExpanded } ) UnstyledBottomSheet(state = sheetState) { Sheet { BasicText( text = "Hide sheet", modifier = Modifier.clickable { sheetState.targetDetent = SheetDetent.Hidden } ) } } ``` ### Waiting for the sheet animation Use the suspend `animateTo()` function to wait until the sheet animation is done: ```kotlin expandable val scope = rememberCoroutineScope() BasicText( text = "Show sheet", modifier = Modifier.clickable { scope.launch { sheetState.animateTo(SheetDetent.FullyExpanded) } } ) ``` ### Moving the sheet instantly Use the `jumpTo()` function to move to a detent without animation: ```kotlin expandable BasicText( text = "Open immediately", modifier = Modifier.clickable { sheetState.jumpTo(SheetDetent.FullyExpanded) } ) ``` ### Creating sheets with custom detents Use the `SheetDetent` constructor to create a new detent. Pass a unique identifier and a function that calculates the detent height. The calculated height cannot be smaller than `0.dp`, taller than the container, or taller than the sheet content. Keep this calculation fast. It runs during sheet measurement. Make sure to pass your new detent when creating your bottom sheet state: ```kotlin expandable val Peek = SheetDetent("peek") { containerHeight, sheetHeight -> containerHeight * 0.6f } val sheetState = rememberBottomSheetState( initialDetent = Peek, detents = listOf(SheetDetent.Hidden, Peek, SheetDetent.FullyExpanded), ) UnstyledBottomSheet(state = sheetState) { Sheet { DragIndication() } } ``` ### Updating detents after the state is created Use the `invalidateDetents()` function to recalculate sheet detents. This is useful when a custom detent reads a measured value that can change, such as a header height: ```kotlin expandable val peekHeight = remember { mutableStateOf(96.dp) } val Peek = remember { SheetDetent("peek") { _, _ -> peekHeight.value } } val sheetState = rememberBottomSheetState( initialDetent = Peek, detents = listOf(Peek, SheetDetent.FullyExpanded), ) LaunchedEffect(peekHeight.value) { sheetState.invalidateDetents() } ``` ### Using scrollable sheet content Use a scrollable layout inside the `Sheet` component to make content scroll within the current detent height: ### Working with the soft keyboard Use the `offsetForIme` parameter to automatically move the sheet above the soft keyboard: ```kotlin expandable var value by remember { mutableStateOf("") } UnstyledBottomSheet( state = sheetState, offsetForIme = true, ) { Sheet { BasicTextField( value = value, onValueChange = { value = it }, ) } } ``` ### Customizing sheet animation between detents Use the `animationSpec` parameter to customize the default animation between detents: ```kotlin expandable val sheetState = rememberBottomSheetState( initialDetent = SheetDetent.Hidden, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow, ), ) ``` ## API Reference ### rememberBottomSheetState | Parameter | Type | Description | |-----------|------|-------------| | `initialDetent` | `SheetDetent` | A `SheetDetent` which controls the height in which the sheet will be introduced within its container. | | `detents` | `List` | | | `animationSpec` | `AnimationSpec` | An `AnimationSpec` used when animating the sheet across the different *sheetDetents*. | | `confirmDetentChange` | `(SheetDetent) -> Boolean` | | | `decayAnimationSpec` | `DecayAnimationSpec` | | | `velocityThreshold` | `() -> Dp` | | | `positionalThreshold` | `(totalDistance: Dp) -> Dp` | | ### BottomSheetState | Parameter | Type | Description | |-----------|------|-------------| | `confirmDetentChange` | `(SheetDetent) -> Boolean` | | | `detents` | `List` | | | `currentDetent` | `SheetDetent` | The `SheetDetent` in which the sheet is currently rested on. Setting a new detent will cause the sheet to animate to that detent. | | `targetDetent` | `SheetDetent` | The `SheetDetent` in which the sheet is about to rest on, if it is being dragged or animated. | | `isIdle` | `Boolean` | Whether the sheet is currently resting at a specific detent. | | `offset` | `Float` | The current offset of the sheet. | | `closestDetent` | `SheetDetent?` | | | `onIndicationClicked` | `() -> Unit` | | | `fun progress()` | `(SheetDetent, SheetDetent) -> Float` | | | `suspend fun animateTo()` | `suspend (SheetDetent, AnimationSpec?) -> Unit` | Animates the sheet to the given detent. This is a `suspend` function, which you can use to wait until the animation is complete. | | `fun jumpTo()` | `(SheetDetent) -> Unit` | Makes the sheet to immediately appear to the given detent without any animation. | | `fun invalidateDetents()` | `() -> Unit` | | | `fun UnstyledBottomSheet()` | `(BottomSheetState, Modifier, Boolean, Boolean, Boolean, BottomSheetScope.() -> Unit` | | ### BottomSheet | Parameter | Type | Description | |-----------|------|-------------| | `state` | `BottomSheetState` | The `BottomSheetState` for the component | | `modifier` | `Modifier` | The `Modifier` for the component | | `enabled` | `Boolean` | Enables or disables dragging. | | `offsetForIme` | `Boolean` | | | `measureContentBeyondContainerBounds` | `Boolean` | | | `content` | `BottomSheetScope.() -> Unit` | The contents of the sheet. | ### BottomSheetScope.Sheet | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | The `Modifier` for the component | | `content` | `() -> Unit` | The contents of the sheet. | ### BottomSheetScope.DragIndication | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | The `Modifier` for the component | | `indication` | `Indication?` | | | `interactionSource` | `MutableInteractionSource?` | | --- --- title: Modal Bottom Sheet description: A dismissible modal bottom sheet with custom detents. --- > **Recommendation:** For new interfaces, use [Drawer](https://composeunstyled.com/docs/components.mddrawer/) instead. It is a more flexible abstraction and the recommended API moving forward. ```kotlin expandable title="ModalBottomSheetDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/modalbottomsheet/ModalBottomSheetDemo.kt" import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.DragIndication import com.composeunstyled.Scrim import com.composeunstyled.Sheet import com.composeunstyled.SheetDetent import com.composeunstyled.SheetDetent.Companion.FullyExpanded import com.composeunstyled.SheetDetent.Companion.Hidden import com.composeunstyled.Text import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledModalBottomSheet import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.scrimToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.rememberModalBottomSheetState import com.composeunstyled.theme.Theme @Preview @Composable fun ModalBottomSheetDemo() { val Peek = SheetDetent("peek") { containerHeight, _ -> containerHeight * 0.6f } val modalSheetState = rememberModalBottomSheetState( initialDetent = Peek, detents = listOf(Hidden, Peek, FullyExpanded), ) Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { UnstyledButton( onClick = { modalSheetState.targetDetent = Peek }, modifier = Modifier .heightIn(32.dp) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), contentPadding = PaddingValues(horizontal = 10.dp), indication = LocalIndication.current, ) { Text("Show bottom sheet") } UnstyledModalBottomSheet( state = modalSheetState, overlay = { Scrim( scrimColor = Theme[colors][scrimToken], enter = fadeIn(), exit = fadeOut(), ) }, ) { Box( modifier = Modifier .fillMaxWidth(), contentAlignment = Alignment.TopCenter, ) { Sheet( modifier = Modifier .widthIn(max = 640.dp) .fillMaxWidth() .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), ) { Box( modifier = Modifier.fillMaxWidth().height(1000.dp), contentAlignment = Alignment.TopCenter, ) { DragIndication( modifier = Modifier .padding(top = 22.dp) .background(Theme[colors][borderToken]) .size(32.dp, 4.dp), indication = LocalIndication.current, ) } } } } } } ``` ## Features - Custom detents - Custom overlay content - Back press and outside click dismissal - Dynamic content sizing - Soft-keyboard support - Scrollable content without fixed height ## Installation ```kotlin implementation("com.composables:composeunstyled-modal-bottom-sheet:2.10.0") ``` ## Anatomy ```kotlin val sheetState = rememberModalBottomSheetState( initialDetent = SheetDetent.Hidden, ) UnstyledModalBottomSheet(state = sheetState) { Sheet { DragIndication() } } ``` ## Concepts - `SheetDetent` defines a height where the sheet can rest. - `UnstyledModalBottomSheet` represents the modal layer the sheet is rendered in. - `Sheet` is the rendered bit of the sheet. - `DragIndication` adds an interactive handle for expand, collapse, and dismiss actions. ## Accessibility Use `DragIndication` when your sheet can move between multiple detents. It provides semantic expand, collapse, and dismiss actions so users can control the sheet without dragging. ## Code Examples ### Showing and hiding a modal bottom sheet Use the `targetDetent` property to animate the modal sheet to a new detent: ```kotlin expandable val sheetState = rememberModalBottomSheetState( initialDetent = SheetDetent.Hidden, ) BasicText( text = "Show sheet", modifier = Modifier.clickable { sheetState.targetDetent = SheetDetent.FullyExpanded }, ) UnstyledModalBottomSheet(state = sheetState) { Sheet { BasicText( text = "Hide sheet", modifier = Modifier.clickable { sheetState.targetDetent = SheetDetent.Hidden }, ) } } ``` ### Waiting for modal bottom sheet animations Use the suspend `animateTo()` function to wait until the sheet animation is done: ```kotlin expandable val scope = rememberCoroutineScope() BasicText( text = "Show sheet", modifier = Modifier.clickable { scope.launch { sheetState.animateTo(SheetDetent.FullyExpanded) } }, ) ``` ### Opening a modal bottom sheet instantly Use the `jumpTo()` function to move to a detent without animation: ```kotlin expandable BasicText( text = "Open immediately", modifier = Modifier.clickable { sheetState.jumpTo(SheetDetent.FullyExpanded) }, ) ``` ### Adding an overlay behind a modal bottom sheet Use the `overlay` parameter to render content behind the modal sheet. `Scrim` provides a ready-made overlay for modal bottom sheets. ```kotlin expandable UnstyledModalBottomSheet( state = sheetState, overlay = { Scrim() }, ) { Sheet { BasicText("Sheet content") } } ``` ### Creating modal bottom sheets with custom detents Use the `SheetDetent` constructor to create a new detent. Pass a unique identifier and a function that calculates the detent height. The calculated height cannot be smaller than `0.dp`, taller than the container, or taller than the sheet content. Keep this calculation fast. It runs during sheet measurement. ```kotlin expandable val Peek = SheetDetent("peek") { containerHeight, sheetHeight -> minOf(containerHeight * 0.4f, sheetHeight) } val sheetState = rememberModalBottomSheetState( initialDetent = SheetDetent.Hidden, detents = listOf(SheetDetent.Hidden, Peek, SheetDetent.FullyExpanded), ) ``` ### Updating modal bottom sheet detents after layout changes Use the `invalidateDetents()` function to recalculate sheet detents. This is useful when a custom detent reads a measured value that can change, such as a header height: ```kotlin expandable var peekHeight by remember { mutableStateOf(120.dp) } val Peek = remember { SheetDetent("peek") { _, _ -> peekHeight } } val sheetState = rememberModalBottomSheetState( initialDetent = Peek, detents = listOf(Peek, SheetDetent.FullyExpanded), ) LaunchedEffect(peekHeight) { sheetState.invalidateDetents() } ``` ### Building modal bottom sheets with scrollable content Use a scrollable layout inside the `Sheet` component to make content scroll within the current detent height: ### Moving a modal bottom sheet above the soft keyboard Use the `offsetForIme` parameter on `ModalBottomSheetProperties` to automatically move the sheet above the soft keyboard: ```kotlin expandable val textState = rememberTextFieldState() UnstyledModalBottomSheet( state = sheetState, properties = ModalBottomSheetProperties(offsetForIme = true), ) { Sheet { BasicTextField(state = textState) } } ``` ### Disabling back press and outside click dismissal Use the `properties` parameter to control how the modal sheet can be dismissed: ```kotlin expandable UnstyledModalBottomSheet( state = sheetState, properties = ModalBottomSheetProperties( dismissOnBackPress = false, dismissOnClickOutside = false, ), ) { Sheet { BasicText("Sheet content") } } ``` ### Reacting to modal bottom sheet dismissal Use the `onDismiss` parameter to run code when the sheet is dismissed: ```kotlin expandable UnstyledModalBottomSheet( state = sheetState, onDismiss = { selectedItem = null }, ) { Sheet { BasicText("Sheet content") } } ``` ### Customizing modal bottom sheet animation between detents Use the `animationSpec` parameter to customize the default animation between detents. Use the `dismissAnimationSpec` parameter to customize the animation used when the modal sheet is dismissed: ```kotlin expandable val sheetState = rememberModalBottomSheetState( initialDetent = SheetDetent.Hidden, animationSpec = tween(durationMillis = 300), dismissAnimationSpec = tween(durationMillis = 180), ) ``` ## API Reference ### rememberModalBottomSheetState | Parameter | Type | Description | |-----------|------|-------------| | `initialDetent` | `SheetDetent` | A `SheetDetent` which controls the height in which the sheet will be introduced within its container. | | `detents` | `List` | A list of `SheetDetent` which the sheet can be rested for dragging purposes. | | `animationSpec` | `AnimationSpec` | An `AnimationSpec` used when animating the sheet across the different *sheetDetents*. | | `dismissAnimationSpec` | `AnimationSpec?` | | | `velocityThreshold` | `() -> Dp` | The velocity threshold (in px per second) that the end velocity has to exceed in order to animate to the next state, even if the `positionalThreshold` has not been reached. | | `positionalThreshold` | `(totalDistance: Dp) -> Dp` | The positional threshold, in px, to be used when calculating the target state while a drag is in progress and when settling after the drag ends. This is the distance from the start of a transition. It will be, depending on the direction of the interaction, added or subtracted from/to the origin offset. It should always be a positive value. | | `confirmDetentChange` | `(SheetDetent) -> Boolean` | | | `decayAnimationSpec` | `DecayAnimationSpec` | | ### ModalBottomSheetState | Parameter | Type | Description | |-----------|------|-------------| | `bottomSheetState` | `BottomSheetState` | | | `modalState` | `ModalState` | | | `currentDetent` | `SheetDetent` | The `SheetDetent` in which the sheet is currently rested on. Setting a new detent will cause the sheet to animate to that detent. | | `targetDetent` | `SheetDetent` | The `SheetDetent` in which the sheet is about to rest on, if it is being dragged or animated. | | `isIdle` | `Boolean` | Whether the sheet is currently resting at a specific detent. | | `offset` | `Float` | The current offset of the sheet. | | `fun progress()` | `(SheetDetent, SheetDetent) -> Float` | | | `suspend fun animateTo()` | `suspend (SheetDetent) -> Unit` | Animates the sheet to the given detent. This is a `suspend` function, which you can use to wait until the animation is complete. | | `fun jumpTo()` | `(SheetDetent) -> Unit` | Makes the sheet to immediately appear to the given detent without any animation. | | `fun invalidateDetents()` | `() -> Unit` | | ### UnstyledModalBottomSheet | Parameter | Type | Description | |-----------|------|-------------| | `state` | `ModalBottomSheetState` | The `ModalBottomSheetState` for the component | | `enabled` | `Boolean` | Enables or disables dragging. | | `properties` | `ModalBottomSheetProperties` | `ModalSheetProperties` that control whether the sheet needs to be dismissed on clicked outside, etc. | | `onDismiss` | `() -> Unit` | Called when the sheet is being dismissed either by tapping outside or by pressing `Esc` or `Back`. | | `overlay` | `(ModalBottomSheetOverlayScope.() -> Unit)?` | | | `content` | `ModalBottomSheetScope.() -> Unit` | The contents of the Modal Bottom Sheet. | ### ModalBottomSheetOverlayScope.Scrim | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | The `Modifier` for the component | | `scrimColor` | `Color` | | | `enter` | `EnterTransition` | | | `exit` | `ExitTransition` | | ### ModalBottomSheetScope.Sheet | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | The `Modifier` for the component | | `content` | `() -> Unit` | The contents of the sheet. | ### ModalBottomSheetScope.DragIndication | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | | | `indication` | `Indication?` | | | `interactionSource` | `MutableInteractionSource?` | | --- --- title: Button description: A button component for custom button styles. --- ```kotlin expandable title="ButtonDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/button/ButtonDemo.kt" import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.heightIn import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.Text import com.composeunstyled.UnstyledButton import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme @Preview @Composable fun ButtonDemo() { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { UnstyledButton( onClick = { }, modifier = Modifier .clip(RectangleShape) .heightIn(32.dp) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken], RectangleShape), contentPadding = PaddingValues(horizontal = 10.dp), indication = LocalIndication.current, ) { Text("Button") } } } ``` ## Installation ```kotlin implementation("com.composables:composeunstyled-button:2.10.0") ``` ## Anatomy ```kotlin UnstyledButton(onClick = onClick) { } ``` ## Concepts - `UnstyledButton` represents the clickable button surface. ## Accessibility `UnstyledButton` uses `Role.Button` by default. Enter and Space activate it. ## Code Examples ### Disabling a button Use the `enabled` parameter to prevent button activation: ```kotlin expandable UnstyledButton( enabled = false, onClick = { submit() }, ) { BasicText("Submit") } ``` ## API Reference ### UnstyledButton | Parameter | Type | Description | |-----------|------|-------------| | `onClick` | `() -> Unit` | The callback to be invoked when the button is clicked. | | `enabled` | `Boolean` | Whether the button is enabled. | | `contentPadding` | `PaddingValues` | Padding values for the content. | | `modifier` | `Modifier` | Modifier to be applied to the button. | | `role` | `Role` | The role of the button for accessibility purposes. | | `indication` | `Indication?` | The indication to be shown when the button is interacted with. | | `interactionSource` | `MutableInteractionSource?` | The interaction source for the button. | | `contentAlignment` | `Alignment` | | | `content` | `() -> Unit` | A composable function that defines the content of the button. | --- --- title: Checkbox description: A checkbox component with full control over the indicator, bounds, and checked animation. --- ```kotlin expandable title="CheckboxDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/checkbox/CheckboxDemo.kt" import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.CheckedIndicator import com.composeunstyled.UnstyledCheckbox import com.composeunstyled.UnstyledIcon import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme @Preview @Composable fun CheckboxDemo() { var checked by remember { mutableStateOf(true) } Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { UnstyledCheckbox( checked = checked, onCheckedChange = { checked = it }, modifier = Modifier.clip(RectangleShape), accessibilityLabel = "Enable notifications", indication = LocalIndication.current, ) { CheckedIndicator( modifier = Modifier .size(24.dp) .background(Theme[colors][surfaceToken], RectangleShape) .border(1.dp, Theme[colors][borderToken], RectangleShape), indication = LocalIndication.current, ) { UnstyledIcon(checkIcon()) } } } } @Composable private fun checkIcon(): ImageVector { val color = Theme[colors][contentToken] return remember(color) { ImageVector.Builder( name = "Check", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f, ).apply { path( fill = null, fillAlpha = 1.0f, stroke = SolidColor(color), strokeAlpha = 1.0f, strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round, strokeLineMiter = 1.0f, pathFillType = PathFillType.NonZero, ) { moveTo(20f, 6f) lineTo(9f, 17f) lineToRelative(-5f, -5f) } }.build() } } ``` ## Features - Custom interaction bounds - Custom checked indicator - Animated checked content - Accessibility label ## Installation ```kotlin implementation("com.composables:composeunstyled-checkbox:2.10.0") ``` ## Anatomy ```kotlin UnstyledCheckbox( checked = checked, onCheckedChange = onCheckedChange, ) { CheckedIndicator { } } ``` ## Concepts - `UnstyledCheckbox` represents the interactive bounds of the checkbox. - `CheckedIndicator` represents the visible checked state. It automatically shows and hides its content based on the `UnstyledCheckbox` state. - Give `CheckedIndicator` a fixed size when its content only exists while checked. Without a fixed size, the checkbox can change layout size when the indicator appears or disappears. ## Accessibility Screen readers will automatically read any text placed inside `UnstyledCheckbox`. Use the `accessibilityLabel` parameter when the checkbox has no visible text label. ## Code Examples ### Making an entire row checkable Use the `UnstyledCheckbox` component as the row container to make the full row toggleable. This is useful when the label should also toggle the checkbox: ```kotlin expandable UnstyledCheckbox( checked = checked, onCheckedChange = { checked = it }, ) { Row { CheckedIndicator { BasicText("✓") } BasicText("Accept all terms") } } ``` ### Creating larger checkbox interaction bounds Use padding on the `CheckedIndicator` component to place the visible checkbox inside a larger interaction area. This is useful when the ripple or touch target should be larger than the visible checkbox: ### Animating the checked indicator Use the `enter` and `exit` parameters on `CheckedIndicator` to animate the checked content. ```kotlin expandable UnstyledCheckbox( checked = checked, onCheckedChange = { checked = it }, ) { CheckedIndicator( enter = fadeIn(), exit = fadeOut(), ) { BasicText("Selected") } } ``` ### Creating a custom checked indicator animation Use the `checked` state value to draw your own indicator instead of `CheckedIndicator`. This is useful when your design system needs a custom checkmark animation: ### Labeling an icon-only checkbox Use the `accessibilityLabel` parameter when the checkbox content has no text: ```kotlin expandable UnstyledCheckbox( checked = checked, onCheckedChange = { checked = it }, accessibilityLabel = "Enable notifications", ) { CheckedIndicator { BasicText("✓") } } ``` ## API Reference ### UnstyledCheckbox | Parameter | Type | Description | |-----------|------|-------------| | `checked` | `Boolean` | Whether the checkbox is checked. | | `onCheckedChange` | `(Boolean) -> Unit` | Callback when the checked state changes. | | `modifier` | `Modifier` | Modifier to be applied to the checkbox. | | `enabled` | `Boolean` | | | `interactionSource` | `MutableInteractionSource?` | | | `indication` | `Indication?` | | | `accessibilityLabel` | `String?` | | | `content` | `CheckboxScope.() -> Unit` | | ### CheckboxScope.CheckedIndicator | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Modifier to be applied to the checkbox. | | `indication` | `Indication?` | | | `enter` | `EnterTransition` | | | `exit` | `ExitTransition` | | | `content` | `AnimatedVisibilityScope.() -> Unit` | | --- --- title: TriStateCheckbox description: A three-state checkbox component for checked, unchecked, and indeterminate values. --- ```kotlin expandable title="TriStateCheckboxDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/tristatecheckbox/TriStateCheckboxDemo.kt" import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composables.icons.lucide.Check import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Minus import com.composeunstyled.CheckedIndicator import com.composeunstyled.StateIndicator import com.composeunstyled.Text import com.composeunstyled.UnstyledCheckbox import com.composeunstyled.UnstyledIcon import com.composeunstyled.UnstyledTriStateCheckbox import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme @Preview @Composable fun TriStateCheckboxDemo() { val checkboxOptions = listOf("Option 1", "Option 2", "Option 3", "Option 4") var selected by remember { mutableStateOf(listOf(true, true, false, false)) } val triState = when { selected.all { it } -> ToggleableState.On selected.none { it } -> ToggleableState.Off else -> ToggleableState.Indeterminate } Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Column( modifier = Modifier .widthIn(max = 300.dp) .fillMaxWidth() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { UnstyledTriStateCheckbox( value = triState, onClick = { val newState = when (triState) { ToggleableState.Off -> true ToggleableState.Indeterminate -> true ToggleableState.On -> false } selected = List(checkboxOptions.size) { newState } }, modifier = Modifier.fillMaxWidth(), accessibilityLabel = "Select all options", indication = null, ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { StateIndicator( modifier = Modifier .clip(RectangleShape) .size(24.dp) .background(Theme[colors][surfaceToken], RectangleShape) .border(1.dp, Theme[colors][borderToken], RectangleShape), indication = LocalIndication.current, ) { state -> when (state) { ToggleableState.On -> UnstyledIcon( Lucide.Check, contentDescription = null, tint = Theme[colors][contentToken], ) ToggleableState.Indeterminate -> UnstyledIcon( Lucide.Minus, contentDescription = null, tint = Theme[colors][contentToken], ) ToggleableState.Off -> Unit } } Spacer(Modifier.width(12.dp)) Text( "Select All", color = Theme[colors][contentToken], ) } } checkboxOptions.forEachIndexed { index, option -> UnstyledCheckbox( checked = selected[index], onCheckedChange = { checked -> selected = selected.toMutableList().apply { this[index] = checked } }, modifier = Modifier.fillMaxWidth(), accessibilityLabel = option, indication = null, ) { Row( modifier = Modifier.fillMaxWidth().padding(start = 36.dp), verticalAlignment = Alignment.CenterVertically, ) { CheckedIndicator( modifier = Modifier .clip(RectangleShape) .size(24.dp) .background(Theme[colors][surfaceToken], RectangleShape) .border(1.dp, Theme[colors][borderToken], RectangleShape), indication = LocalIndication.current, ) { UnstyledIcon( Lucide.Check, contentDescription = null, tint = Theme[colors][contentToken], ) } Spacer(Modifier.width(12.dp)) Text(option, color = Theme[colors][contentToken]) } } } } } } ``` ## Installation ```kotlin implementation("com.composables:composeunstyled-tri-state-checkbox:2.10.0") ``` ## Anatomy ```kotlin UnstyledTriStateCheckbox( value = value, onClick = onClick, ) { StateIndicator { } } ``` ## Concepts - `UnstyledTriStateCheckbox` represents the tri-state checkbox interaction target. - `StateIndicator` renders content for the current `ToggleableState`. ## Accessibility Use tri-state checkboxes for parent selection controls where only some child items are selected. ## Code Examples ### Rendering each checkbox state Use the `StateIndicator` component to render content for each `ToggleableState`: ```kotlin expandable UnstyledTriStateCheckbox( value = value, onClick = { toggleParent() }, ) { StateIndicator { state -> when (state) { ToggleableState.On -> BasicText("Selected") ToggleableState.Off -> BasicText("Not selected") ToggleableState.Indeterminate -> BasicText("Partially selected") } } } ``` ### Building a select-all checkbox Use the `ToggleableState.Indeterminate` value when only some items are selected: ```kotlin expandable val selectedCount = selectedItems.count() val parentState = when (selectedCount) { 0 -> ToggleableState.Off items.size -> ToggleableState.On else -> ToggleableState.Indeterminate } UnstyledTriStateCheckbox( value = parentState, onClick = { selectedItems = if (parentState == ToggleableState.On) emptySet() else items.toSet() }, ) { StateIndicator { state -> BasicText(state.toString()) } } ``` ## API Reference ### UnstyledTriStateCheckbox | Parameter | Type | Description | |-----------|------|-------------| | `value` | `ToggleableState` | | | `onClick` | `() -> Unit` | Callback invoked when the checkbox is clicked | | `modifier` | `Modifier` | Modifier to be applied to the checkbox | | `enabled` | `Boolean` | Whether the checkbox is enabled for interaction (defaults to `true`) | | `interactionSource` | `MutableInteractionSource?` | MutableInteractionSource for handling interactions | | `indication` | `Indication?` | Visual indication for interactions | | `accessibilityLabel` | `String?` | | | `content` | `TriStateCheckboxScope.() -> Unit` | | ### TriStateCheckboxScope | Parameter | Type | Description | |-----------|------|-------------| | `value` | `ToggleableState` | | | `enabled` | `Boolean` | | | `interactionSource` | `MutableInteractionSource` | | ### TriStateCheckboxScope.StateIndicator | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Modifier to be applied to the checkbox | | `indication` | `Indication?` | Visual indication for interactions | | `content` | `(ToggleableState) -> Unit` | | --- --- title: Dialog description: A modal dialog component with dismiss behavior and panel transitions. --- ```kotlin expandable title="DialogDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/dialog/DialogDemo.kt" import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.composeunstyled.DialogPanel import com.composeunstyled.Scrim import com.composeunstyled.Text import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledDialog import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.scrimToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme @Preview @Composable fun DialogDemo() { var dialogVisible by remember { mutableStateOf(true) } Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { UnstyledButton( onClick = { dialogVisible = true }, modifier = Modifier .clip(RectangleShape) .heightIn(32.dp) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken], RectangleShape), contentPadding = PaddingValues(horizontal = 10.dp), indication = LocalIndication.current, ) { Text("Show dialog") } UnstyledDialog( visible = dialogVisible, onDismissRequest = { dialogVisible = false }, overlay = { Scrim( scrimColor = Theme[colors][scrimToken], enter = fadeIn(), exit = fadeOut(), ) }, ) { Box( modifier = Modifier .fillMaxSize(), contentAlignment = Alignment.Center, ) { DialogPanel( modifier = Modifier .padding(20.dp) .displayCutoutPadding() .systemBarsPadding() .widthIn(max = 560.dp) .padding(20.dp) .clip(RectangleShape) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken], RectangleShape), paneTitle = "Dialog", enter = scaleIn(initialScale = 0.8f) + fadeIn(tween(durationMillis = 250)), exit = scaleOut(targetScale = 0.6f) + fadeOut(tween(durationMillis = 150)), ) { Column { Column(Modifier.padding(start = 24.dp, top = 24.dp, end = 24.dp)) { Text( text = "Update Available", color = Theme[colors][contentToken], fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.Medium, ) Spacer(Modifier.height(8.dp)) Text( text = "A new version of the app is available. " + "Please update to the latest version.", color = Theme[colors][contentToken], ) } Spacer(Modifier.height(24.dp)) UnstyledButton( onClick = { dialogVisible = false }, modifier = Modifier .padding(12.dp) .align(Alignment.End) .clip(RectangleShape), indication = LocalIndication.current, ) { Text( "Update", modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), color = Theme[colors][contentToken], ) } } } } } } } ``` ## Features - Modal focus behavior - Outside-click dismiss - Back and Escape dismiss - Panel enter and exit transitions ## Installation ```kotlin implementation("com.composables:composeunstyled-dialog:2.10.0") ``` ## Anatomy ```kotlin UnstyledDialog( visible = visible, onDismissRequest = { visible = false }, ) { DialogPanel { } } ``` ## Concepts - `UnstyledDialog` renders dialog content in a modal layer. - `DialogPanel` renders the focusable dialog content. ## Accessibility Use the `paneTitle` parameter on `DialogPanel` when the dialog has a clear title. ## Code Examples ### Showing and hiding a dialog Use the `visible` parameter to show the dialog and the `onDismissRequest` callback to update that state: ```kotlin expandable var visible by remember { mutableStateOf(false) } BasicText( text = "Show dialog", modifier = Modifier.clickable { visible = true }, ) UnstyledDialog( visible = visible, onDismissRequest = { visible = false }, ) { DialogPanel { BasicText("Dialog content") } } ``` ### Adding an overlay behind a dialog Use the `overlay` parameter to render content behind the dialog panel. `Scrim` provides a ready-made overlay for dialogs. ```kotlin expandable UnstyledDialog( visible = visible, onDismissRequest = { visible = false }, overlay = { Scrim() }, ) { DialogPanel { BasicText("Dialog content") } } ``` ### Changing dismiss behavior Use the `properties` parameter to control how the dialog can be dismissed: ```kotlin expandable UnstyledDialog( visible = visible, onDismissRequest = { visible = false }, properties = DialogProperties( dismissOnBackPress = false, dismissOnClickOutside = false, ), ) { DialogPanel { BasicText("Dialog content") } } ``` ### Animating the dialog panel Use the `enter` and `exit` parameters on `DialogPanel` to animate the dialog panel: ```kotlin expandable UnstyledDialog( visible = visible, onDismissRequest = { visible = false }, ) { DialogPanel( enter = fadeIn(), exit = fadeOut(), ) { BasicText("Dialog content") } } ``` ## API Reference ### UnstyledDialog | Parameter | Type | Description | |-----------|------|-------------| | `visible` | `Boolean` | | | `onDismissRequest` | `() -> Unit` | | | `properties` | `DialogProperties` | Properties that control when the dialog needs to be dismissed (such as clicking outside of the panel or pressing Esc or Back. | | `overlay` | `(DialogOverlayScope.() -> Unit)?` | | | `content` | `DialogScope.() -> Unit` | A `@Composable` function that provides a `DialogScope`. | ### DialogOverlayScope.Scrim | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | `Modifier` for the Scrim | | `scrimColor` | `Color` | Controls the color of the Scrim. The default color is Black with an alpha of 60%. | | `enter` | `EnterTransition` | The `EnterTransition` when the Scrim enters the composition | | `exit` | `ExitTransition` | The `ExitTransition` when the Scrim enters the composition | ### DialogScope.DialogPanel | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | `Modifier` for the Scrim | | `paneTitle` | `String?` | | | `enter` | `EnterTransition` | The `EnterTransition` when the Scrim enters the composition | | `exit` | `ExitTransition` | The `ExitTransition` when the Scrim enters the composition | | `content` | `() -> Unit` | A `@Composable` function that provides a `DialogScope`. | --- --- title: Disclosure description: An expandable content component with a dedicated trigger and content slot. --- ```kotlin expandable title="DisclosureDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/disclosure/DisclosureDemo.kt" import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.composables.icons.lucide.ChevronDown import com.composables.icons.lucide.Lucide import com.composeunstyled.DisclosedContent import com.composeunstyled.DisclosureButton import com.composeunstyled.Text import com.composeunstyled.UnstyledDisclosure import com.composeunstyled.UnstyledIcon import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme @Preview @Composable fun DisclosureDemo() { var expanded by remember { mutableStateOf(false) } Box( modifier = Modifier.fillMaxSize().padding(top = 24.dp), contentAlignment = Alignment.TopCenter, ) { UnstyledDisclosure( expanded = expanded, onExpandedChange = { expanded = it }, ) { Column( modifier = Modifier .widthIn(max = 560.dp) .clip(RectangleShape) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken], RectangleShape), ) { DisclosureButton( modifier = Modifier.fillMaxWidth(), indication = LocalIndication.current, ) { Row( modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp, horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Text("What is Compose Unstyled", modifier = Modifier.weight(1f)) val degrees by animateFloatAsState(if (expanded) -180f else 0f, tween()) UnstyledIcon( imageVector = Lucide.ChevronDown, contentDescription = null, modifier = Modifier.rotate(degrees), tint = Theme[colors][contentToken], ) } } DisclosedContent( enter = expandVertically( spring( stiffness = Spring.StiffnessMediumLow, visibilityThreshold = IntSize.VisibilityThreshold, ), ), exit = shrinkVertically(), ) { Text( "Compose Unstyled is a collection of unstyled, accessible UI components for Compose " + "Multiplatform. It provides the building blocks for creating beautiful, consistent " + "user interfaces.", modifier = Modifier.padding(16.dp).alpha(0.66f), ) } } } } } ``` ## Installation ```kotlin implementation("com.composables:composeunstyled-disclosure:2.10.0") ``` ## Anatomy ```kotlin UnstyledDisclosure( expanded = expanded, onExpandedChange = onExpandedChange, ) { DisclosureButton { } DisclosedContent { } } ``` ## Concepts - `UnstyledDisclosure` represents an expandable region. - `DisclosureButton` renders the trigger that toggles the disclosure. - `DisclosedContent` renders content only while the disclosure is expanded. ## Accessibility Use `DisclosureButton` for the disclosure trigger so assistive technology receives expand and collapse actions. ## Code Examples ### Showing hidden content Use the `expanded` parameter to control whether `DisclosedContent` is visible: ```kotlin expandable var expanded by remember { mutableStateOf(false) } UnstyledDisclosure( expanded = expanded, onExpandedChange = { expanded = it }, ) { DisclosureButton { BasicText(if (expanded) "Hide details" else "Show details") } DisclosedContent { BasicText("Details") } } ``` ### Animating disclosed content Use the `enter` and `exit` parameters on `DisclosedContent` to animate the disclosed content: ```kotlin expandable UnstyledDisclosure( expanded = expanded, onExpandedChange = { expanded = it }, ) { DisclosureButton { BasicText("Details") } DisclosedContent( enter = expandVertically(), exit = shrinkVertically(), ) { BasicText("Hidden content") } } ``` ## API Reference ### UnstyledDisclosure | Parameter | Type | Description | |-----------|------|-------------| | `expanded` | `Boolean` | Controls whether the disclosure is expanded. | | `onExpandedChange` | `(Boolean) -> Unit` | | | `modifier` | `Modifier` | Modifier to be applied to the panel. | | `content` | `DisclosureScope.() -> Unit` | A composable function that defines the content of the panel. | ### UnstyledDisclosureButton | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Modifier to be applied to the panel. | | `enabled` | `Boolean` | Indicates if the heading is enabled. | | `contentPadding` | `PaddingValues` | Padding values for the content. | | `indication` | `Indication?` | The indication to be shown when the heading is interacted with. | | `interactionSource` | `MutableInteractionSource?` | The interaction source for the heading. | | `contentAlignment` | `Alignment` | | | `content` | `() -> Unit` | A composable function that defines the content of the panel. | ### UnstyledDisclosedContent | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Modifier to be applied to the panel. | | `enter` | `EnterTransition` | The enter transition for the panel. | | `exit` | `ExitTransition` | The exit transition for the panel. | | `content` | `() -> Unit` | A composable function that defines the content of the panel. | ### DisclosureScope.DisclosureButton | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Modifier to be applied to the panel. | | `enabled` | `Boolean` | Indicates if the heading is enabled. | | `contentPadding` | `PaddingValues` | Padding values for the content. | | `indication` | `Indication?` | The indication to be shown when the heading is interacted with. | | `interactionSource` | `MutableInteractionSource?` | The interaction source for the heading. | | `contentAlignment` | `Alignment` | | | `content` | `() -> Unit` | A composable function that defines the content of the panel. | ### DisclosureScope.DisclosedContent | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Modifier to be applied to the panel. | | `enter` | `EnterTransition` | The enter transition for the panel. | | `exit` | `ExitTransition` | The exit transition for the panel. | | `content` | `() -> Unit` | A composable function that defines the content of the panel. | --- --- title: Drawer description: An unstyled, draggable edge-attached panel for drawers, side sheets, and sheets. --- ```kotlin expandable title="DrawerDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/drawer/DrawerDemo.kt" import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.DragHandle import com.composeunstyled.DrawerSnapPoint import com.composeunstyled.DrawerSnapPoints import com.composeunstyled.Panel import com.composeunstyled.Text import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledDrawer import com.composeunstyled.UnstyledDrawerState import com.composeunstyled.Viewport import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme private enum class DrawerDemoValue { Closed, Open, } @Preview @Composable fun DrawerDemo() { val snapPoints = remember { DrawerSnapPoints { DrawerDemoValue.Closed at DrawerSnapPoint.Zero DrawerDemoValue.Open at DrawerSnapPoint.ContentSize } } val drawerState = remember { UnstyledDrawerState( initialValue = DrawerDemoValue.Open, snapPoints = snapPoints, ) } Box(Modifier.fillMaxSize()) { UnstyledButton( onClick = { drawerState.targetValue = DrawerDemoValue.Open }, contentPadding = PaddingValues(12.dp), modifier = Modifier .align(Alignment.Center) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Open drawer") } UnstyledDrawer(state = drawerState) { Viewport(Modifier.fillMaxSize()) { Panel( modifier = Modifier .fillMaxWidth() .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]) .padding(start = 24.dp, top = 12.dp, end = 24.dp, bottom = 24.dp), ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { DragHandle { Box( Modifier .width(32.dp) .height(4.dp) .background(Theme[colors][contentToken]), ) } Text("Here is the content of the drawer.") UnstyledButton( onClick = { drawerState.targetValue = DrawerDemoValue.Closed }, contentPadding = PaddingValues(12.dp), modifier = Modifier .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Close") } } } } } } } ``` The default demo shows a modal bottom drawer with a draggable handle. ## Features - Named snap points for closed, peek, and expanded panel states - Start, end, top, and bottom placement - Modal, overlay, and in-place presentation without prescribed visuals - Gesture, outside-click, Back, Escape, and accessibility dismissal support - Caller-controlled Android system-bar icon appearance while a drawer is presented ## Installation ```kotlin implementation("com.composables:composeunstyled-drawer:2.10.0") ``` ## Composition ```kotlin DrawerHost { UnstyledDrawer { Viewport { Panel { DragHandle() } } SwipeArea() } } ``` ## Concepts - `UnstyledDrawerState` maps your values to visible panel sizes and controls movement between them. - `DrawerSnapPoints` associates each state value with `Zero`, `ContentSize`, or a custom visible size. - `UnstyledDrawer` selects the placement, presentation, dismissal behavior, and caller-owned overlay. - `Viewport` defines the finite area used to measure the panel and resolve snap points. - `Panel` is the draggable surface. Its modifier and content own all visual and size choices. - `DrawerHost` provides the portal layer required by `DrawerPresentation.Overlay`. ## Accessibility Use `DragHandle` when the drawer can move between snap points. It exposes expand, collapse, and dismiss actions to assistive technology. Modal drawers block outside interaction while open and expose outside dismissal when a `Zero` snap point and `dismissOnClickOutside` are present. Give the panel content an appropriate accessible name and use semantic controls for its actions. ## Choosing a presentation Use `Modal` for a flow that stands apart from the current screen, such as a modal bottom sheet, account picker, or confirmation panel. It renders in a modal layer and blocks interaction behind the drawer. It does not need `DrawerHost`. Use `Overlay` for a drawer that covers the app without taking control away from it, such as a desktop inspector, navigation panel, or shortcut tray. It renders through `DrawerHost` and leaves the rest of the app interactive. It is the only presentation that supports `SwipeArea`. Use `InPlace` when the drawer belongs to one bounded part of the UI, such as a preview pane, editor, or embedded workflow. It renders where you declare it without a modal layer or portal. Its panel overlays its `Viewport`; it does not push sibling content. ## Code Examples ### Show and hide a drawer Assign `targetValue` to animate to a supported value. A value mapped to `DrawerSnapPoint.Zero` represents a fully closed drawer. ```kotlin expandable enum class DrawerValue { Closed, Open } val drawerState = remember { UnstyledDrawerState( initialValue = DrawerValue.Closed, snapPoints = DrawerSnapPoints { DrawerValue.Closed at DrawerSnapPoint.Zero DrawerValue.Open at DrawerSnapPoint.ContentSize }, ) } UnstyledButton( onClick = { drawerState.targetValue = DrawerValue.Open }, modifier = Modifier .background(Color.White) .border(1.dp, Color.Black), ) { BasicText("Open drawer") } UnstyledDrawer(state = drawerState) { Viewport { Panel( modifier = Modifier .background(Color.White) .border(1.dp, Color.Black), ) { UnstyledButton( onClick = { drawerState.targetValue = DrawerValue.Closed }, modifier = Modifier .background(Color.White) .border(1.dp, Color.Black), ) { BasicText("Close drawer") } } } } ``` ### Add a peek state Use a percentage snap point to keep part of the drawer visible. ```kotlin expandable title="DrawerPeekDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/drawer/DrawerPeekDemo.kt" import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.DragHandle import com.composeunstyled.DrawerSnapPoint import com.composeunstyled.DrawerSnapPoints import com.composeunstyled.Panel import com.composeunstyled.Text import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledDrawer import com.composeunstyled.UnstyledDrawerState import com.composeunstyled.Viewport import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme private enum class DrawerPeekDemoValue { Closed, Peek, Expanded, } @Preview @Composable fun DrawerPeekDemo() { val drawerState = remember { UnstyledDrawerState( initialValue = DrawerPeekDemoValue.Peek, snapPoints = DrawerSnapPoints { DrawerPeekDemoValue.Closed at DrawerSnapPoint.Zero DrawerPeekDemoValue.Peek at DrawerSnapPoint { viewportSize, _ -> viewportSize * 0.2f } DrawerPeekDemoValue.Expanded at DrawerSnapPoint.ContentSize }, ) } Box(Modifier.fillMaxSize()) { UnstyledButton( onClick = { drawerState.targetValue = DrawerPeekDemoValue.Peek }, contentPadding = PaddingValues(12.dp), modifier = Modifier .align(Alignment.Center) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Show drawer") } UnstyledDrawer( state = drawerState, modifier = Modifier.fillMaxSize(), ) { Viewport(Modifier.fillMaxSize()) { Panel( modifier = Modifier .fillMaxWidth() .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]) .padding(start = 24.dp, top = 12.dp, end = 24.dp, bottom = 24.dp), ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { DragHandle { Box( Modifier .width(32.dp) .height(4.dp) .background(Theme[colors][contentToken]), ) } Text("Here is the content of the drawer.") UnstyledButton( onClick = { drawerState.targetValue = DrawerPeekDemoValue.Expanded }, contentPadding = PaddingValues(12.dp), modifier = Modifier .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Expand") } UnstyledButton( onClick = { drawerState.targetValue = DrawerPeekDemoValue.Peek }, contentPadding = PaddingValues(12.dp), modifier = Modifier .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Peek") } } } } } } } ``` ### Set a custom snap point This drawer's middle state uses half of the viewport height. ```kotlin expandable title="DrawerCustomSnapPointDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/drawer/DrawerCustomSnapPointDemo.kt" import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.DrawerSnapPoint import com.composeunstyled.DrawerSnapPoints import com.composeunstyled.Panel import com.composeunstyled.Text import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledDrawer import com.composeunstyled.UnstyledDrawerState import com.composeunstyled.Viewport import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme private enum class DrawerCustomSnapPointDemoValue { Closed, Half, Open, } @Preview @Composable fun DrawerCustomSnapPointDemo() { val drawerState = remember { UnstyledDrawerState( initialValue = DrawerCustomSnapPointDemoValue.Half, snapPoints = DrawerSnapPoints { DrawerCustomSnapPointDemoValue.Closed at DrawerSnapPoint.Zero DrawerCustomSnapPointDemoValue.Half at DrawerSnapPoint { viewportSize, _ -> viewportSize * 0.5f } DrawerCustomSnapPointDemoValue.Open at DrawerSnapPoint.ContentSize }, ) } Box(Modifier.fillMaxSize()) { Column( modifier = Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text("The half state uses half of the viewport height.") UnstyledButton( onClick = { drawerState.targetValue = DrawerCustomSnapPointDemoValue.Half }, contentPadding = PaddingValues(12.dp), modifier = Modifier .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Show half") } } UnstyledDrawer( state = drawerState, modifier = Modifier.fillMaxSize(), ) { Viewport(Modifier.fillMaxSize()) { Panel( modifier = Modifier .fillMaxWidth() .fillMaxHeight() .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]) .padding(24.dp), ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text("Here is the content of the drawer.") UnstyledButton( onClick = { drawerState.targetValue = DrawerCustomSnapPointDemoValue.Open }, contentPadding = PaddingValues(12.dp), modifier = Modifier .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Open fully") } UnstyledButton( onClick = { drawerState.targetValue = DrawerCustomSnapPointDemoValue.Closed }, contentPadding = PaddingValues(12.dp), modifier = Modifier .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Close") } } } } } } } ``` ### Build a side sheet Set the placement to `Start` and give the panel a fixed width. ```kotlin expandable title="DrawerSideSheetDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/drawer/DrawerSideSheetDemo.kt" import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.DrawerHost import com.composeunstyled.DrawerPlacement import com.composeunstyled.DrawerPresentation import com.composeunstyled.DrawerSnapPoint import com.composeunstyled.DrawerSnapPoints import com.composeunstyled.Panel import com.composeunstyled.SwipeArea import com.composeunstyled.Text import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledDrawer import com.composeunstyled.UnstyledDrawerState import com.composeunstyled.Viewport import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.inputBackgroundToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme private enum class DrawerSideSheetDemoValue { Closed, Open, } @Preview @Composable fun DrawerSideSheetDemo() { val drawerState = remember { UnstyledDrawerState( initialValue = DrawerSideSheetDemoValue.Open, snapPoints = DrawerSnapPoints { DrawerSideSheetDemoValue.Closed at DrawerSnapPoint.Zero DrawerSideSheetDemoValue.Open at DrawerSnapPoint.ContentSize }, ) } DrawerHost(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) { UnstyledButton( onClick = { drawerState.targetValue = DrawerSideSheetDemoValue.Open }, contentPadding = PaddingValues(12.dp), modifier = Modifier .align(Alignment.Center) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Open side sheet") } UnstyledDrawer( state = drawerState, modifier = Modifier.fillMaxSize(), placement = DrawerPlacement.Start, presentation = DrawerPresentation.Overlay, ) { Viewport(Modifier.fillMaxSize()) { Panel( modifier = Modifier .width(288.dp) .fillMaxHeight() .background(Theme[colors][inputBackgroundToken]) .border(1.dp, Theme[colors][borderToken]) .padding(start = 12.dp, top = 24.dp, end = 24.dp, bottom = 24.dp), ) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text("Here is the content of the drawer.") UnstyledButton( onClick = { drawerState.targetValue = DrawerSideSheetDemoValue.Closed }, contentPadding = PaddingValues(12.dp), modifier = Modifier .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Close") } } } } SwipeArea( modifier = Modifier .width(24.dp) .fillMaxHeight(), ) } } } } ``` ### Open from the screen edge Add `SwipeArea` to an overlay drawer to open it from the outlined edge target. ```kotlin expandable title="DrawerSwipeAreaDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/drawer/DrawerSwipeAreaDemo.kt" import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.DrawerHost import com.composeunstyled.DrawerPlacement import com.composeunstyled.DrawerPresentation import com.composeunstyled.DrawerSnapPoint import com.composeunstyled.DrawerSnapPoints import com.composeunstyled.Panel import com.composeunstyled.SwipeArea import com.composeunstyled.Text import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledDrawer import com.composeunstyled.UnstyledDrawerState import com.composeunstyled.Viewport import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.inputBackgroundToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme private enum class DrawerSwipeAreaDemoValue { Closed, Open, } @Preview @Composable fun DrawerSwipeAreaDemo() { val drawerState = remember { UnstyledDrawerState( initialValue = DrawerSwipeAreaDemoValue.Closed, snapPoints = DrawerSnapPoints { DrawerSwipeAreaDemoValue.Closed at DrawerSnapPoint.Zero DrawerSwipeAreaDemoValue.Open at DrawerSnapPoint.ContentSize }, ) } DrawerHost(Modifier.fillMaxSize()) { UnstyledDrawer( state = drawerState, modifier = Modifier.fillMaxSize(), placement = DrawerPlacement.Start, presentation = DrawerPresentation.Overlay, ) { Box(Modifier.fillMaxSize()) { val swipeAreaModifier = Modifier .align(Alignment.CenterStart) .width(200.dp) .fillMaxHeight() Box( modifier = swipeAreaModifier.border(1.dp, Theme[colors][borderToken]), contentAlignment = Alignment.Center, ) { Text( "Start a swipe anywhere inside this area", modifier = Modifier.padding(16.dp), ) } Viewport(Modifier.fillMaxSize()) { Panel( modifier = Modifier .width(288.dp) .fillMaxHeight() .background(Theme[colors][inputBackgroundToken]) .border(1.dp, Theme[colors][borderToken]) .padding(start = 12.dp, top = 24.dp, end = 24.dp, bottom = 24.dp), ) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text("Here is the content of the drawer.") UnstyledButton( onClick = { drawerState.targetValue = DrawerSwipeAreaDemoValue.Closed }, contentPadding = PaddingValues(12.dp), modifier = Modifier .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Close") } } } } SwipeArea(swipeAreaModifier) } } } } ``` ### Add an overlay Provide `Overlay` to render caller-owned content behind the drawer. ```kotlin expandable title="DrawerOverlayDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/drawer/DrawerOverlayDemo.kt" import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.DragHandle import com.composeunstyled.DrawerSnapPoint import com.composeunstyled.DrawerSnapPoints import com.composeunstyled.Overlay import com.composeunstyled.Panel import com.composeunstyled.Text import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledDrawer import com.composeunstyled.UnstyledDrawerState import com.composeunstyled.Viewport import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.scrimToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme private enum class DrawerOverlayDemoValue { Closed, Open, } @Preview @Composable fun DrawerOverlayDemo() { val drawerState = remember { UnstyledDrawerState( initialValue = DrawerOverlayDemoValue.Open, snapPoints = DrawerSnapPoints { DrawerOverlayDemoValue.Closed at DrawerSnapPoint.Zero DrawerOverlayDemoValue.Open at DrawerSnapPoint.ContentSize }, ) } Box(Modifier.fillMaxSize()) { UnstyledButton( onClick = { drawerState.targetValue = DrawerOverlayDemoValue.Open }, contentPadding = PaddingValues(12.dp), modifier = Modifier .align(Alignment.Center) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Open drawer") } UnstyledDrawer( state = drawerState, modifier = Modifier.fillMaxSize(), overlay = { Overlay( modifier = Modifier.fillMaxSize().background( Theme[colors][scrimToken], ), enter = fadeIn(), exit = fadeOut(), ) }, ) { Viewport(Modifier.fillMaxSize()) { Panel( modifier = Modifier .fillMaxWidth() .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]) .padding(start = 24.dp, top = 12.dp, end = 24.dp, bottom = 24.dp), ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { DragHandle { Box( Modifier .width(32.dp) .height(4.dp) .background(Theme[colors][contentToken]), ) } Text("Here is the content of the drawer.") UnstyledButton( onClick = { drawerState.targetValue = DrawerOverlayDemoValue.Closed }, contentPadding = PaddingValues(12.dp), modifier = Modifier .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Close") } } } } } } } ``` ### Disable gestures Set `gesturesEnabled` to `false` when the drawer should only move through app controls. ```kotlin expandable UnstyledDrawer( state = drawerState, gesturesEnabled = false, ) { Viewport { Panel { Text("Drawer content") } } } ``` ### Disable outside click dismissal Set `dismissOnClickOutside` to `false` when a click outside a modal drawer must not close it. ```kotlin expandable UnstyledDrawer( state = drawerState, dismissOnClickOutside = false, ) { Viewport { Panel { Text("Drawer content") } } } ``` ### Disable Back and Escape dismissal Set `dismissOnNavigateBack` to `false` when Back and Escape must not close the drawer. ```kotlin expandable UnstyledDrawer( state = drawerState, dismissOnNavigateBack = false, ) { Viewport { Panel { Text("Drawer content") } } } ``` ### Prevent a state change Return `false` from `confirmValueChange` to reject a transition, such as closing an unsaved form. ```kotlin expandable title="DrawerConfirmValueChangeDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/drawer/DrawerConfirmValueChangeDemo.kt" import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composables.icons.lucide.Check import com.composables.icons.lucide.Lucide import com.composeunstyled.CheckedIndicator import com.composeunstyled.DrawerSnapPoint import com.composeunstyled.DrawerSnapPoints import com.composeunstyled.Panel import com.composeunstyled.Text import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledCheckbox import com.composeunstyled.UnstyledDrawer import com.composeunstyled.UnstyledDrawerState import com.composeunstyled.UnstyledIcon import com.composeunstyled.Viewport import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme private enum class DrawerConfirmValueChangeDemoValue { Closed, Open, } @Preview @Composable fun DrawerConfirmValueChangeDemo() { var canClose by remember { mutableStateOf(false) } val drawerState = remember { UnstyledDrawerState( initialValue = DrawerConfirmValueChangeDemoValue.Open, snapPoints = DrawerSnapPoints { DrawerConfirmValueChangeDemoValue.Closed at DrawerSnapPoint.Zero DrawerConfirmValueChangeDemoValue.Open at DrawerSnapPoint.ContentSize }, confirmValueChange = { change -> change.targetValue != DrawerConfirmValueChangeDemoValue.Closed || canClose }, ) } Box(Modifier.fillMaxSize()) { UnstyledButton( onClick = { drawerState.targetValue = DrawerConfirmValueChangeDemoValue.Open }, contentPadding = PaddingValues(12.dp), modifier = Modifier .align(Alignment.Center) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Open drawer") } UnstyledDrawer( state = drawerState, modifier = Modifier.fillMaxSize(), ) { Viewport(Modifier.fillMaxSize()) { Panel( modifier = Modifier .fillMaxWidth() .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]) .padding(24.dp), ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text("Here is the content of the drawer.") Text(if (canClose) "Closing is allowed." else "Closing is blocked.") UnstyledCheckbox( checked = canClose, onCheckedChange = { canClose = it }, modifier = Modifier.fillMaxWidth(), accessibilityLabel = "Allow closing", indication = LocalIndication.current, ) { Row(verticalAlignment = Alignment.CenterVertically) { CheckedIndicator( modifier = Modifier .clip(RectangleShape) .size(24.dp) .background(Theme[colors][surfaceToken], RectangleShape) .border(1.dp, Theme[colors][borderToken], RectangleShape), indication = LocalIndication.current, ) { UnstyledIcon(Lucide.Check) } Spacer(Modifier.width(12.dp)) Text("Allow closing") } } UnstyledButton( onClick = { drawerState.targetValue = DrawerConfirmValueChangeDemoValue.Closed }, contentPadding = PaddingValues(12.dp), modifier = Modifier .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Close") } } } } } } } ``` ### Control Android system-bar icons Set `systemUi` to match the icon color needed by your drawer. The drawer applies the requested appearance while it is presented and restores the previous appearance after it closes. ```kotlin expandable UnstyledDrawer( state = drawerState, placement = DrawerPlacement.Bottom, presentation = DrawerPresentation.Modal, systemUi = SystemUi( statusBar = SystemUiAppearance.Dark, navigationBar = SystemUiAppearance.Dark, ), ) { Viewport { Panel( modifier = Modifier .fillMaxWidth() .background(Color.White) .border(1.dp, Color.Black), ) { BasicText("Drawer with dark system-bar icons") } } } ``` ### Working with the soft keyboard Use the IME window inset in `Viewport` to keep the drawer above the soft keyboard. ```kotlin expandable title="DrawerImeDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/drawer/DrawerImeDemo.kt" import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import com.composeunstyled.DragHandle import com.composeunstyled.DrawerPanelAlignment import com.composeunstyled.DrawerPlacement import com.composeunstyled.DrawerSnapPoint import com.composeunstyled.DrawerSnapPoints import com.composeunstyled.Panel import com.composeunstyled.SystemUi import com.composeunstyled.SystemUiAppearance import com.composeunstyled.Text import com.composeunstyled.TextInput import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledDrawer import com.composeunstyled.UnstyledDrawerState import com.composeunstyled.UnstyledTextField import com.composeunstyled.Viewport import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme private enum class DrawerImeDemoValue { Closed, Open, } @Preview @Composable fun DrawerImeDemo() { val snapPoints = remember { DrawerSnapPoints { DrawerImeDemoValue.Closed at DrawerSnapPoint.Zero DrawerImeDemoValue.Open at DrawerSnapPoint.ContentSize } } val drawerState = remember { UnstyledDrawerState( initialValue = DrawerImeDemoValue.Closed, snapPoints = snapPoints, ) } val input = rememberTextFieldState() val fieldTextStyle = TextStyle(fontSize = 16.sp, lineHeight = 24.sp) Box(Modifier.fillMaxSize()) { Box( modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center, ) { UnstyledButton( onClick = { drawerState.targetValue = DrawerImeDemoValue.Open }, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), modifier = Modifier.background( Theme[colors][surfaceToken], ).border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Open drawer") } } UnstyledDrawer( state = drawerState, modifier = Modifier.fillMaxSize(), placement = DrawerPlacement.Bottom, systemUi = SystemUi( statusBar = SystemUiAppearance.Light, navigationBar = SystemUiAppearance.Dark, ), ) { Box(Modifier.fillMaxSize()) { Viewport( modifier = Modifier.fillMaxSize(), panelAlignment = DrawerPanelAlignment.Center, windowInsets = WindowInsets.ime, ) { Panel( modifier = Modifier .widthIn(max = 640.dp) .fillMaxWidth() .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), ) { Box { Column( modifier = Modifier .verticalScroll(rememberScrollState()) .windowInsetsPadding(WindowInsets.navigationBars) .padding(start = 24.dp, top = 60.dp, end = 24.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( "Here is the content of the drawer.", fontSize = 24.sp, lineHeight = 32.sp, ) Text( "Focus a field to test the keyboard inset.", fontSize = 14.sp, lineHeight = 20.sp, ) UnstyledTextField( state = input, modifier = Modifier.fillMaxWidth(), accessibilityLabel = "First field", lineLimits = TextFieldLineLimits.SingleLine, cursorBrush = SolidColor(Theme[colors][contentToken]), textStyle = fieldTextStyle, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { TextInput( modifier = Modifier .fillMaxWidth() .border(1.dp, Theme[colors][borderToken]) .padding(horizontal = 12.dp, vertical = 10.dp), placeholder = { Text("Type here", style = fieldTextStyle) }, ) } } UnstyledButton( onClick = { drawerState.targetValue = DrawerImeDemoValue.Closed }, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), modifier = Modifier .fillMaxWidth() .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Close drawer") } } Box( modifier = Modifier .align(Alignment.TopCenter) .zIndex(1f) .fillMaxWidth() .padding(top = 12.dp), contentAlignment = Alignment.TopCenter, ) { DragHandle { Box(Modifier.width(32.dp).height(4.dp).background(Theme[colors][contentToken])) } } } } } } } } } ``` ### Applying Overscroll Effect Pass an `OverscrollEffect` to `Panel` to customize its motion beyond a snap point. ```kotlin expandable title="DrawerOverscrollDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/drawer/DrawerOverscrollDemo.kt" import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animate import androidx.compose.animation.core.spring import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.LayoutModifierNode import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import com.composeunstyled.DragHandle import com.composeunstyled.DrawerPlacement import com.composeunstyled.DrawerPresentation import com.composeunstyled.DrawerSnapPoint import com.composeunstyled.DrawerSnapPoints import com.composeunstyled.Panel import com.composeunstyled.Text import com.composeunstyled.UnstyledDrawer import com.composeunstyled.UnstyledDrawerState import com.composeunstyled.Viewport import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlin.math.roundToInt private enum class DrawerOverscrollDemoValue { Closed, Open, } @Preview @Composable fun DrawerOverscrollDemo() { val drawerState = remember { UnstyledDrawerState( initialValue = DrawerOverscrollDemoValue.Open, snapPoints = DrawerSnapPoints { DrawerOverscrollDemoValue.Closed at DrawerSnapPoint.Zero DrawerOverscrollDemoValue.Open at DrawerSnapPoint.ContentSize }, ) } val overscrollEffect = remember { ElasticOverscrollEffect() } Box(Modifier.fillMaxSize()) { UnstyledDrawer( state = drawerState, modifier = Modifier.fillMaxSize(), placement = DrawerPlacement.Bottom, presentation = DrawerPresentation.InPlace, ) { Viewport(Modifier.fillMaxSize()) { Panel( modifier = Modifier .fillMaxWidth() .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]) .padding(start = 24.dp, top = 12.dp, end = 24.dp, bottom = 24.dp), overscrollEffect = overscrollEffect, ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { DragHandle { Box( Modifier .width(32.dp) .height(4.dp) .background(Theme[colors][contentToken]), ) } Text("Here is the content of the drawer.") Text("Pull upward past the open limit") } } } } } } private class ElasticOverscrollEffect : OverscrollEffect { var offsetPx: Float by mutableFloatStateOf(0f) private set override fun applyToScroll( delta: Offset, source: androidx.compose.ui.input.nestedscroll.NestedScrollSource, performScroll: (Offset) -> Offset, ): Offset { val consumed = performScroll(delta) val overscroll = delta - consumed offsetPx += overscroll.y * ElasticOverscrollOffsetMultiplier return consumed } override suspend fun applyToFling( velocity: Velocity, performFling: suspend (Velocity) -> Velocity, ) { val releaseOffset = offsetPx if (releaseOffset == 0f) { performFling(velocity) return } coroutineScope { val rebound = launch { animate( initialValue = releaseOffset, targetValue = 0f, initialVelocity = velocity.y * ElasticOverscrollOffsetMultiplier, animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium, ), ) { value, _ -> offsetPx = value } } try { performFling(velocity) } finally { rebound.join() } } } override val isInProgress: Boolean get() = offsetPx != 0f override val node: DelegatableNode = object : Modifier.Node(), LayoutModifierNode { override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints, ): MeasureResult { val placeable = measurable.measure(constraints) return layout(placeable.width, placeable.height) { val offset = IntOffset(x = 0, y = offsetPx.roundToInt()) placeable.placeRelativeWithLayer(offset.x, offset.y) } } } } private const val ElasticOverscrollOffsetMultiplier = 0.55f ``` ### Add spacing around a drawer Set `Viewport` window insets to leave space around the drawer. ```kotlin expandable title="DrawerSpacingDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/drawer/DrawerSpacingDemo.kt" import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.DragHandle import com.composeunstyled.DrawerSnapPoint import com.composeunstyled.DrawerSnapPoints import com.composeunstyled.Panel import com.composeunstyled.Text import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledDrawer import com.composeunstyled.UnstyledDrawerState import com.composeunstyled.Viewport import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme private enum class DrawerSpacingDemoValue { Closed, Open, } @Preview @Composable fun DrawerSpacingDemo() { val drawerState = remember { UnstyledDrawerState( initialValue = DrawerSpacingDemoValue.Open, snapPoints = DrawerSnapPoints { DrawerSpacingDemoValue.Closed at DrawerSnapPoint.Zero DrawerSpacingDemoValue.Open at DrawerSnapPoint.ContentSize }, ) } val spacing = with(LocalDensity.current) { 24.dp.roundToPx() } Box(Modifier.fillMaxSize()) { UnstyledButton( onClick = { drawerState.targetValue = DrawerSpacingDemoValue.Open }, contentPadding = PaddingValues(12.dp), modifier = Modifier .align(Alignment.Center) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Open drawer") } UnstyledDrawer( state = drawerState, modifier = Modifier.fillMaxSize(), ) { Viewport( modifier = Modifier.fillMaxSize(), windowInsets = WindowInsets( left = spacing, right = spacing, bottom = spacing, ), ) { Panel( modifier = Modifier .fillMaxWidth() .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]) .padding(start = 24.dp, top = 12.dp, end = 24.dp, bottom = 24.dp), ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { DragHandle { Box( Modifier .width(32.dp) .height(4.dp) .background(Theme[colors][contentToken]), ) } Text("Here is the content of the drawer.") UnstyledButton( onClick = { drawerState.targetValue = DrawerSpacingDemoValue.Closed }, contentPadding = PaddingValues(12.dp), modifier = Modifier .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Close") } } } } } } } ``` ### Handle dynamic content Use `DrawerSnapPoint.ContentSize` to track a panel as its content changes. ```kotlin expandable title="DrawerDynamicContentDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/drawer/DrawerDynamicContentDemo.kt" import androidx.compose.animation.animateContentSize import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.DragHandle import com.composeunstyled.DrawerSnapPoint import com.composeunstyled.DrawerSnapPoints import com.composeunstyled.Panel import com.composeunstyled.Text import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledDrawer import com.composeunstyled.UnstyledDrawerState import com.composeunstyled.Viewport import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme private enum class DrawerDynamicContentDemoValue { Closed, Open, } @Preview @Composable fun DrawerDynamicContentDemo() { val drawerState = remember { UnstyledDrawerState( initialValue = DrawerDynamicContentDemoValue.Open, snapPoints = DrawerSnapPoints { DrawerDynamicContentDemoValue.Closed at DrawerSnapPoint.Zero DrawerDynamicContentDemoValue.Open at DrawerSnapPoint.ContentSize }, ) } var showDetails by remember { mutableStateOf(false) } Box(Modifier.fillMaxSize()) { UnstyledButton( onClick = { drawerState.targetValue = DrawerDynamicContentDemoValue.Open }, contentPadding = PaddingValues(12.dp), modifier = Modifier .align(Alignment.Center) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Open drawer") } UnstyledDrawer( state = drawerState, modifier = Modifier.fillMaxSize(), ) { Viewport(Modifier.fillMaxSize()) { Panel( modifier = Modifier .fillMaxWidth() .animateContentSize() .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]) .padding(start = 24.dp, top = 12.dp, end = 24.dp, bottom = 24.dp), ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { DragHandle { Box( Modifier .width(32.dp) .height(4.dp) .background(Theme[colors][contentToken]), ) } Text("Here is the content of the drawer.") if (showDetails) { Text("Additional content.") } UnstyledButton( onClick = { showDetails = showDetails.not() }, contentPadding = PaddingValues(12.dp), modifier = Modifier .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text(if (showDetails) "Remove content" else "Add content") } } } } } } } ``` ### Detect why a drawer was dismissed Read the dismissal reason from the change passed to `onDismissed`. ```kotlin expandable title="DrawerDismissalReasonDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/drawer/DrawerDismissalReasonDemo.kt" import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.DragHandle import com.composeunstyled.DrawerSnapPoint import com.composeunstyled.DrawerSnapPoints import com.composeunstyled.DrawerValueChange import com.composeunstyled.Panel import com.composeunstyled.Text import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledDrawer import com.composeunstyled.UnstyledDrawerState import com.composeunstyled.Viewport import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme private enum class DrawerDismissalReasonDemoValue { Closed, Open, } @Preview @Composable fun DrawerDismissalReasonDemo() { val drawerState = remember { UnstyledDrawerState( initialValue = DrawerDismissalReasonDemoValue.Open, snapPoints = DrawerSnapPoints { DrawerDismissalReasonDemoValue.Closed at DrawerSnapPoint.Zero DrawerDismissalReasonDemoValue.Open at DrawerSnapPoint.ContentSize }, ) } var dismissalReason by remember { mutableStateOf("No dismissal yet") } Box(Modifier.fillMaxSize()) { Column( modifier = Modifier.align(Alignment.Center).padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text("Dismissal reason: $dismissalReason") UnstyledButton( onClick = { drawerState.targetValue = DrawerDismissalReasonDemoValue.Open }, contentPadding = PaddingValues(12.dp), modifier = Modifier .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Open drawer") } } UnstyledDrawer( state = drawerState, modifier = Modifier.fillMaxSize(), onDismissed = { dismissalReason = it.reason.label() }, ) { Viewport(Modifier.fillMaxSize()) { Panel( modifier = Modifier .fillMaxWidth() .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]) .padding(start = 24.dp, top = 12.dp, end = 24.dp, bottom = 24.dp), ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { DragHandle { Box( Modifier .width(32.dp) .height(4.dp) .background(Theme[colors][contentToken]), ) } Text("Here is the content of the drawer.") Text("Close with a gesture, outside click, Back, or Escape.") UnstyledButton( onClick = { drawerState.targetValue = DrawerDismissalReasonDemoValue.Closed }, contentPadding = PaddingValues(12.dp), modifier = Modifier .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken]), indication = LocalIndication.current, ) { Text("Close") } } } } } } } private fun DrawerValueChange.Reason.label(): String = when (this) { DrawerValueChange.Reason.Gesture -> "Gesture" DrawerValueChange.Reason.NavigateBack -> "Navigate back" DrawerValueChange.Reason.ClickOutside -> "Click outside" DrawerValueChange.Reason.AccessibilityAction -> "Accessibility action" DrawerValueChange.Reason.Programmatic -> "Programmatic" else -> "Unknown" } ``` ## API Reference ### UnstyledDrawer | Parameter | Type | Description | |-----------|------|-------------| | `state` | `UnstyledDrawerState` | Controls the drawer's current and target values. | | `modifier` | `Modifier` | Applied to the drawer's top-level container. | | `placement` | `DrawerPlacement` | Edge from which the panel appears. | | `presentation` | `DrawerPresentation` | Whether the drawer is modal, overlaid through a portal, or rendered in place. | | `gesturesEnabled` | `Boolean` | Whether users can drag the panel. | | `dismissOnNavigateBack` | `Boolean` | Whether Back and Escape dismiss a visible drawer. | | `dismissOnClickOutside` | `Boolean` | Whether outside interaction dismisses a modal drawer. | | `onDismissed` | `(change: DrawerValueChange) -> Unit` | Called after the drawer settles at its zero snap point. | | `overlay` | `(DrawerOverlayScope.() -> Unit)?` | Caller-owned content rendered behind the panel. | | `systemUi` | `SystemUi` | System UI behavior while the drawer is presented. | | `content` | `DrawerScope.() -> Unit` | Drawer slots, including `DrawerScope.Viewport` and `DrawerScope.SwipeArea`. | ### DrawerSnapPoint | Parameter | Type | Description | |-----------|------|-------------| | `calculate` | `(viewportSize: Dp, contentSize: Dp) -> Dp` | | ### DrawerHost | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Applied to the host container. | | `content` | `() -> Unit` | Content that can contain overlay drawers. | ### DrawerScope.SwipeArea | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Applied to the swipe area. | ### DrawerScope.Viewport | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Applied to the viewport. | | `panelAlignment` | `DrawerPanelAlignment` | Cross-axis alignment for the panel. | | `windowInsets` | `WindowInsets` | Insets excluded from the available viewport. | | `content` | `DrawerViewportScope.() -> Unit` | The panel slot. | ### DrawerViewportScope.Panel | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Applied to the panel. | | `overscrollEffect` | `OverscrollEffect?` | Optional overscroll behavior for panel dragging. | | `content` | `DrawerPanelScope.() -> Unit` | The panel content and `DrawerPanelScope.DragHandle` slot. | ### DrawerPanelScope.DragHandle | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Applied to the handle. | | `content` | `() -> Unit` | Caller-owned handle content. | ### DrawerOverlayScope.Overlay | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Applied to the overlay. | | `enter` | `EnterTransition` | Transition used when the overlay appears. | | `exit` | `ExitTransition` | Transition used when the overlay disappears. | | `content` | `() -> Unit` | Caller-owned overlay content. | ### SystemUi Controls the Android system-bar icon appearance while a drawer is presented. | Parameter | Type | Description | |-----------|------|-------------| | `statusBar` | `SystemUiAppearance` | The requested status-bar icon appearance. | | `navigationBar` | `SystemUiAppearance` | The requested navigation-bar icon appearance. | --- --- title: Dropdown Menu description: An anchored menu component with keyboard navigation and custom placement. --- ```kotlin expandable title="DropdownMenuDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/dropdownmenu/DropdownMenuDemo.kt" import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composables.icons.lucide.ChevronDown import com.composables.icons.lucide.Clipboard import com.composables.icons.lucide.Copy import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Maximize import com.composables.icons.lucide.Scissors import com.composables.icons.lucide.Trash2 import com.composeunstyled.DropdownMenuPanel import com.composeunstyled.LocalContentColor import com.composeunstyled.Text import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledDropdownMenu import com.composeunstyled.UnstyledDropdownMenuItem import com.composeunstyled.UnstyledHorizontalSeparator import com.composeunstyled.UnstyledIcon import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.errorToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme @Preview @Composable fun DropdownMenuDemo() { class DropdownOption( val text: String, val icon: ImageVector, val enabled: Boolean = true, val dangerous: Boolean = false, ) val options = listOf( DropdownOption("Select All", Lucide.Maximize), DropdownOption("Copy", Lucide.Copy), DropdownOption("Cut", Lucide.Scissors, enabled = false), DropdownOption("Paste", Lucide.Clipboard), DropdownOption("Delete", Lucide.Trash2, dangerous = true), ) var expanded by remember { mutableStateOf(true) } Box( modifier = Modifier.fillMaxSize().padding(top = 24.dp), contentAlignment = Alignment.TopCenter, ) { UnstyledDropdownMenu( expanded = expanded, onExpandedChange = { expanded = it }, sideOffset = 4.dp, panel = { DropdownMenuPanel( modifier = Modifier .width(240.dp) .clip(RectangleShape) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken], RectangleShape), enter = scaleIn( animationSpec = tween(durationMillis = 120, easing = LinearOutSlowInEasing), initialScale = 0.8f, transformOrigin = TransformOrigin(0f, 0f), ) + fadeIn(tween(durationMillis = 30)), exit = scaleOut( animationSpec = tween(durationMillis = 75), targetScale = 0.8f, transformOrigin = TransformOrigin(0f, 0f), ) + fadeOut(tween(durationMillis = 75)), ) { options.forEachIndexed { index, option -> if (index == 1 || index == options.lastIndex) { UnstyledHorizontalSeparator(color = Theme[colors][borderToken]) } UnstyledDropdownMenuItem( onClick = {}, enabled = option.enabled, indication = LocalIndication.current, modifier = Modifier .padding(4.dp) .sizeIn(minWidth = 40.dp, minHeight = 40.dp) .clip(RectangleShape) .fillMaxWidth(), ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 8.dp), horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, ) { val contentColor = ( if (option.dangerous) { Theme[colors][errorToken] } else { LocalContentColor.current } ).copy(alpha = if (option.enabled) 1f else 0.5f) UnstyledIcon( imageVector = option.icon, contentDescription = null, tint = contentColor, ) Spacer(Modifier.width(12.dp)) Text( text = option.text, color = contentColor, ) } } } } }, anchor = { UnstyledButton( onClick = { expanded = true }, modifier = Modifier .sizeIn(minWidth = 40.dp, minHeight = 40.dp) .clip(RectangleShape) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken], RectangleShape), indication = LocalIndication.current, ) { Row( modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text("Options") Spacer(Modifier.width(8.dp)) UnstyledIcon( imageVector = Lucide.ChevronDown, contentDescription = null, tint = Theme[colors][contentToken], ) } } }, ) } } ``` ## Features - Anchor-based placement - Keyboard menu navigation - Auto-dismiss on outside click - Panel enter and exit transitions ## Installation ```kotlin implementation("com.composables:composeunstyled-dropdown-menu:2.10.0") ``` ## Anatomy ```kotlin UnstyledDropdownMenu( expanded = expanded, onExpandedChange = onExpandedChange, panel = { DropdownMenuPanel { UnstyledDropdownMenuItem(onClick = onClick) { } } }, anchor = { }, ) ``` ## Concepts - `UnstyledDropdownMenu` marks the anchor area and renders the floating menu when expanded. - The `anchor` slot renders the content the menu is positioned against. - The `panel` slot renders the floating menu content. - `DropdownMenuPanel` renders the menu surface and arranges direct children vertically. - `UnstyledDropdownMenuItem` renders a focusable item inside `DropdownMenuPanel`. Use direct `UnstyledDropdownMenuItem` children for managed keyboard navigation. Custom nested layouts can be rendered inside the panel, but nested items are not part of the panel-managed menu order. ## Accessibility Dropdown menu handles keyboard interactions out of the box. Pressing Arrow Down opens the menu and focuses the first item. Pressing Arrow Down and Arrow Up moves focus to the next and previous item. Home moves focus to the first item. End moves focus to the last item. Escape closes the menu. Use `UnstyledDropdownMenuItem` for focusable menu actions. ## Code Examples ### Opening and closing a dropdown menu Use the `expanded` parameter to show the menu and the `onExpandedChange` callback to update that state: ```kotlin expandable var expanded by remember { mutableStateOf(false) } UnstyledDropdownMenu( expanded = expanded, onExpandedChange = { expanded = it }, panel = { DropdownMenuPanel { UnstyledDropdownMenuItem(onClick = { expanded = false }) { BasicText("Close") } } }, anchor = { BasicText( text = "Open menu", modifier = Modifier.clickable { expanded = true }, ) }, ) ``` ### Positioning a dropdown menu Use the `side`, `alignment`, `sideOffset`, and `alignmentOffset` parameters to place the menu panel relative to the anchor: ```kotlin expandable UnstyledDropdownMenu( expanded = expanded, onExpandedChange = { expanded = it }, side = AnchorSide.Bottom, alignment = AnchorAlignment.End, sideOffset = 8.dp, panel = { DropdownMenuPanel { UnstyledDropdownMenuItem(onClick = { select() }) { BasicText("Item") } } }, anchor = { BasicText("Open menu") }, ) ``` ### Closing the menu after clicking an item `UnstyledDropdownMenuItem` closes the menu after click by default by calling the dropdown's `onExpandedChange` callback with `false`: ```kotlin expandable UnstyledDropdownMenu( expanded = expanded, onExpandedChange = { expanded = it }, panel = { DropdownMenuPanel { UnstyledDropdownMenuItem(onClick = { select() }) { BasicText("Item") } } }, anchor = { BasicText("Open menu") }, ) ``` ### Keeping the menu open after clicking an item Use the `closeOnClick` parameter when a menu item should update state without dismissing the menu: ```kotlin expandable UnstyledDropdownMenu( expanded = expanded, onExpandedChange = { expanded = it }, panel = { DropdownMenuPanel { UnstyledDropdownMenuItem( closeOnClick = false, onClick = { enabled = enabled.not() }, ) { BasicText("Toggle option") } } }, anchor = { BasicText("Open menu") }, ) ``` ### Animating the dropdown menu Use the `enter` and `exit` parameters on `DropdownMenuPanel` to animate the menu panel: ```kotlin expandable UnstyledDropdownMenu( expanded = expanded, onExpandedChange = { expanded = it }, panel = { DropdownMenuPanel( enter = fadeIn(), exit = fadeOut(), ) { UnstyledDropdownMenuItem(onClick = { select() }) { BasicText("Item") } } }, anchor = { BasicText("Open menu") }, ) ``` ## API Reference ### UnstyledDropdownMenu | Parameter | Type | Description | |-----------|------|-------------| | `expanded` | `Boolean` | | | `onExpandedChange` | `(Boolean) -> Unit` | | | `modifier` | `Modifier` | | | `side` | `AnchorSide` | | | `alignment` | `AnchorAlignment` | | | `sideOffset` | `Dp` | | | `alignmentOffset` | `Dp` | | | `panel` | `DropdownMenuScope.() -> Unit` | | | `anchor` | `() -> Unit` | | ### DropdownMenuScope.DropdownMenuPanel | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | | | `enter` | `EnterTransition` | | | `exit` | `ExitTransition` | | | `content` | `DropdownMenuPanelScope.() -> Unit` | | ### DropdownMenuPanelScope.MenuItem | Parameter | Type | Description | |-----------|------|-------------| | `onClick` | `() -> Unit` | | | `modifier` | `Modifier` | | | `enabled` | `Boolean` | | | `closeOnClick` | `Boolean` | | | `interactionSource` | `MutableInteractionSource?` | | | `indication` | `Indication?` | | | `content` | `() -> Unit` | | --- --- title: Icon description: An icon component for tinted painter, bitmap, and vector assets. --- ```kotlin expandable title="IconDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/icon/IconDemo.kt" import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composables.icons.lucide.Heart import com.composables.icons.lucide.Lucide import com.composeunstyled.UnstyledIcon import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.theme.Theme @Preview @Composable fun IconDemo() { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { UnstyledIcon( imageVector = Lucide.Heart, contentDescription = "Favorite", tint = Theme[colors][contentToken], modifier = Modifier.size(90.dp), ) } } ``` ## Installation ```kotlin implementation("com.composables:composeunstyled-icon:2.10.0") ``` ## Anatomy ```kotlin UnstyledIcon( imageVector = icon, contentDescription = "Favorite", ) ``` ## Concepts - `UnstyledIcon` renders an icon from an `ImageVector`, `Painter`, or `ImageBitmap`. ## Accessibility Pass a short `contentDescription` for icons that communicate meaning. Use `null` for decorative icons. ## Code Examples ### Tinting an icon Use the `tint` parameter to apply one color to the icon: ```kotlin expandable UnstyledIcon( imageVector = favoriteIcon, contentDescription = "Favorite", tint = Color.Red, ) ``` ## API Reference ### UnstyledIcon | Parameter | Type | Description | |-----------|------|-------------| | `painter` | `Painter` | a `Painter` to draw inside this icon. | | `contentDescription` | `String?` | text used by accessibility services to describe what this icon represents. This value can be ommited if the icon is used for stylistic purposes only. | | `modifier` | `Modifier` | the `Modifier` to be used to this icon. | | `tint` | `Color` | a `Color` that will be used to tint the `painter`. If `Color.Unspecified` is passed, then no tinting will be used. | | `imageBitmap` | `ImageBitmap` | | | `imageVector` | `ImageVector` | | --- --- title: Progress Indicator description: A progress indicator component for determinate and indeterminate loading states. --- ```kotlin expandable title="ProgressIndicatorDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/progressindicator/ProgressIndicatorDemo.kt" import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.Indicator import com.composeunstyled.UnstyledProgress import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.milliseconds @Preview @Composable fun ProgressIndicatorDemo() { var hasProgressed by remember { mutableStateOf(false) } val pillShape = RoundedCornerShape(100) val progress by animateFloatAsState( targetValue = if (hasProgressed) 0.85f else 0.2f, animationSpec = tween(durationMillis = 450), ) LaunchedEffect(Unit) { delay(500.milliseconds) hasProgressed = true } Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { UnstyledProgress( progress = progress, modifier = Modifier .width(400.dp) .height(12.dp) .clip(pillShape) .background(Theme[colors][surfaceToken], pillShape) .border(1.dp, Theme[colors][borderToken], pillShape), ) { Indicator(Modifier.background(Theme[colors][contentToken], pillShape)) } } } ``` ## Installation ```kotlin implementation("com.composables:composeunstyled-progress:2.10.0") ``` ## Anatomy ```kotlin UnstyledProgress(progress = progress) { Indicator() } ``` ## Concepts - `UnstyledProgress()` represents the visible bounds of the progress including the track. - `Indicator` fills the available width by the current progress value. ## Accessibility `UnstyledProgress` applies the appropriate accessibility semantics based on whether the `progress` parameter is provided. ## Code Examples ### Creating a determinate progress indicator Use the `progress` parameter when the current progress value is known: ```kotlin expandable UnstyledProgress(progress = 0.4f) { Indicator() } ``` ### Creating an indeterminate progress indicator Use the overload without the `progress` parameter when the current progress value is unknown: ```kotlin expandable UnstyledProgress { BasicText("Loading") } ``` ### Drawing a custom indicator Use the `ProgressScope.progress` property when the indicator needs custom measurement: ```kotlin expandable UnstyledProgress(progress = progress) { Box(Modifier.fillMaxWidth(progress)) } ``` ## API Reference ### UnstyledProgress | Parameter | Type | Description | |-----------|------|-------------| | `progress` | `Float` | | | `modifier` | `Modifier` | Modifier to be applied to the progress container. | | `content` | `ProgressScope.() -> Unit` | A composable function that defines the content of the progress indicator. | | `content` | `() -> Unit` | | ### ProgressScope | Parameter | Type | Description | |-----------|------|-------------| | `progress` | `Float` | | ### ProgressScope.Indicator | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Modifier to apply to the filled progress content. | --- --- title: Radio Group description: A radio group component for single-choice selection. --- ```kotlin expandable title="RadioGroupDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/radiogroup/RadioGroupDemo.kt" import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.SelectedIndicator import com.composeunstyled.Text import com.composeunstyled.UnstyledRadioButton import com.composeunstyled.UnstyledRadioGroup import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme @Preview @Composable fun RadioGroupDemo() { val values = listOf("Light", "Dark", "System") var selectedValue by remember { mutableStateOf("Light") } Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Column( modifier = Modifier .width(300.dp) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { UnstyledRadioGroup( value = selectedValue, onValueChange = { selectedValue = it }, modifier = Modifier.fillMaxWidth(), accessibilityLabel = "Theme selection", ) { Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { values.forEach { value -> val selected = selectedValue == value UnstyledRadioButton( value = value, modifier = Modifier .fillMaxWidth() .clip(RectangleShape), indication = LocalIndication.current, ) { Row( modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp, horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier .size(20.dp) .clip(CircleShape) .background( if (selected) { Theme[colors][contentToken] } else { Theme[colors][surfaceToken] }, ) .border(1.dp, Theme[colors][borderToken], CircleShape), contentAlignment = Alignment.Center, ) { SelectedIndicator( indication = LocalIndication.current, ) { Box( Modifier .size(8.dp) .clip(CircleShape) .background(Theme[colors][surfaceToken]), ) } } Spacer(Modifier.width(16.dp)) Text(value) } } } } } } } } ``` ## Features - Generic radio values - Arrow-key focus movement - Animated selected indicator ## Installation ```kotlin implementation("com.composables:composeunstyled-radio-group:2.10.0") ``` ## Anatomy ```kotlin UnstyledRadioGroup( value = value, onValueChange = onValueChange, ) { RadioButton(value) { SelectedIndicator { } } } ``` ## Concepts - `UnstyledRadioGroup` groups radio options for one selected value. - `RadioButton` renders an option inside the group. - `SelectedIndicator` renders only when its radio button is selected. ## Accessibility Use `accessibilityLabel` when the radio group does not contain a readable group label. ## Code Examples ### Selecting one radio option Use the `value` parameter on each `RadioButton` to connect it to the group value: ```kotlin expandable var selected by remember { mutableStateOf("small") } UnstyledRadioGroup( value = selected, onValueChange = { selected = it }, ) { RadioButton("small") { BasicText("Small") } RadioButton("large") { BasicText("Large") } } ``` ### Rendering the selected indicator Use the `SelectedIndicator` component to render content only for the selected option: ```kotlin expandable RadioButton("large") { SelectedIndicator { BasicText("Selected") } BasicText("Large") } ``` ### Animating the selected indicator Use the `enter` and `exit` parameters on `SelectedIndicator` to animate the selected indicator: ```kotlin expandable RadioButton("large") { SelectedIndicator( enter = fadeIn(), exit = fadeOut(), ) { BasicText("Selected") } } ``` ## API Reference ### UnstyledRadioGroup | Parameter | Type | Description | |-----------|------|-------------| | `value` | `T?` | | | `onValueChange` | `(T) -> Unit` | | | `modifier` | `Modifier` | Modifier to be applied to the radio button. | | `accessibilityLabel` | `String?` | | | `content` | `RadioGroupScope.() -> Unit` | Composable function to define the content of the radio button. | ### RadioGroupScope.RadioButton | Parameter | Type | Description | |-----------|------|-------------| | `value` | `T` | | | `modifier` | `Modifier` | Modifier to be applied to the radio button. | | `enabled` | `Boolean` | Whether the radio button is enabled. | | `interactionSource` | `MutableInteractionSource?` | Interaction source for the radio button. | | `indication` | `Indication?` | Visual indication for interactions. | | `content` | `RadioButtonScope.() -> Unit` | Composable function to define the content of the radio button. | ### RadioButtonScope.SelectedIndicator | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Modifier to be applied to the radio button. | | `indication` | `Indication?` | Visual indication for interactions. | | `enter` | `EnterTransition` | | | `exit` | `ExitTransition` | | | `content` | `AnimatedVisibilityScope.() -> Unit` | Composable function to define the content of the radio button. | --- --- title: Scrollbars description: Scrollbar components for scroll state, lazy lists, and lazy grids. --- ```kotlin expandable title="ScrollbarsDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/scrollbars/ScrollbarsDemo.kt" import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.composeunstyled.Text import com.composeunstyled.Thumb import com.composeunstyled.ThumbVisibility import com.composeunstyled.UnstyledHorizontalScrollbar import com.composeunstyled.UnstyledVerticalScrollbar import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.rememberScrollbarState import com.composeunstyled.theme.Theme import kotlin.time.Duration.Companion.seconds @Preview @Composable fun ScrollbarsDemo() { VerticalScrollbarsDemo() } @Preview @Composable fun VerticalScrollbarsDemo() { Box( modifier = Modifier.fillMaxSize() .padding(vertical = 40.dp) .padding(horizontal = 16.dp), contentAlignment = Alignment.TopCenter, ) { val desserts = listOf( "Cupcake", "Donut", "Eclair", "Froyo", "Gingerbread", "Honeycomb", "Ice Cream Sandwich", "Jelly Bean", "KitKat", "Lollipop", "Marshmallow", "Nougat", "Oreo", "Pie", "Quince", "Red Velvet Cake", "Snow Cone", "Tiramisu", "Upside-down Cake", "Vanilla Custard", "Waffle", "Xmas Pudding", "Yogurt Parfait", "Zabaglione", ) val state = rememberScrollState() val scrollbarState = rememberScrollbarState(state) Box( modifier = Modifier .widthIn(max = 400.dp) .background(Theme[colors][surfaceToken], RectangleShape) .border(1.dp, Theme[colors][borderToken], RectangleShape) .fillMaxSize(), ) { Column( Modifier.verticalScroll(state) .padding(start = 4.dp, end = 16.dp) .fillMaxWidth() .padding(8.dp), ) { Text( "Deserts", Modifier.padding(4.dp), fontSize = 20.sp, fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(12.dp)) desserts.forEach { i -> Text(i, Modifier.padding(4.dp).fillMaxWidth()) Spacer(Modifier.height(12.dp)) } } UnstyledVerticalScrollbar( scrollbarState = scrollbarState, modifier = Modifier .align(Alignment.TopEnd) .width(12.dp) .fillMaxHeight(), ) { Thumb( modifier = Modifier .fillMaxWidth() .padding(2.dp) .height(12.dp) .background(Theme[colors][contentToken].copy(0.33f), RoundedCornerShape(100)), thumbVisibility = ThumbVisibility.AlwaysVisible, ) } } } } @Preview @Composable fun HorizontalScrollbarsDemo() { Box( modifier = Modifier.fillMaxSize() .padding(vertical = 40.dp), contentAlignment = Alignment.TopCenter, ) { val state = rememberScrollState() val scrollbarState = rememberScrollbarState(state) Box( modifier = Modifier .widthIn(max = 400.dp) .background(Theme[colors][surfaceToken], RectangleShape) .border(1.dp, Theme[colors][borderToken], RectangleShape) .wrapContentHeight(), ) { Row( Modifier.horizontalScroll(state) .systemBarsPadding() .navigationBarsPadding() .padding(start = 4.dp, end = 16.dp) .fillMaxWidth() .padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { (1..100).forEach { i -> Box(Modifier.size(90.dp).clip(CircleShape).background(Theme[colors][contentToken])) } } UnstyledHorizontalScrollbar( scrollbarState = scrollbarState, modifier = Modifier .align(Alignment.BottomCenter) .height(12.dp) .fillMaxWidth(), ) { Thumb( modifier = Modifier .fillMaxHeight() .padding(2.dp) .width(12.dp) .background(Theme[colors][contentToken].copy(0.33f), RoundedCornerShape(100)), thumbVisibility = ThumbVisibility.HideWhileIdle( enter = fadeIn(), exit = fadeOut(), hideDelay = 1.seconds, ), ) } } } } ``` ## Features - ScrollState support - LazyListState support - LazyGridState support - Draggable scrollbar thumbs ## Installation ```kotlin implementation("com.composables:composeunstyled-scrollbars:2.10.0") ``` ## Anatomy ```kotlin val scrollbarState = rememberScrollbarState(scrollState) UnstyledVerticalScrollbar(scrollbarState) { Thumb() } ``` ## Concepts - `ScrollbarState` represents the scroll position used by a scrollbar. - `UnstyledVerticalScrollbar` renders a vertical scrollbar. - `UnstyledHorizontalScrollbar` renders a horizontal scrollbar. - `Thumb` renders the draggable thumb inside a scrollbar. ## Code Examples ### Adding scrollbars to LazyColumn Use the `rememberScrollbarState(LazyListState)` function to connect a scrollbar to lazy list scroll position: ```kotlin expandable val listState = rememberLazyListState() val scrollbarState = rememberScrollbarState(listState) LazyColumn(state = listState) { items(100) { index -> BasicText("Item $index") } } UnstyledVerticalScrollbar(scrollbarState) { Thumb() } ``` ### Adding scrollbars to LazyVerticalGrid Use the `rememberScrollbarState(LazyGridState)` function to connect a scrollbar to lazy grid scroll position: ```kotlin expandable val gridState = rememberLazyGridState() val scrollbarState = rememberScrollbarState(gridState) LazyVerticalGrid( columns = GridCells.Fixed(2), state = gridState, ) { items(100) { index -> BasicText("Item $index") } } UnstyledVerticalScrollbar(scrollbarState) { Thumb() } ``` ### Adding scrollbars to scrollable content Use the `rememberScrollbarState(ScrollState)` function for content that uses a regular scroll state: ```kotlin expandable val scrollState = rememberScrollState() val scrollbarState = rememberScrollbarState(scrollState) Column(Modifier.verticalScroll(scrollState)) { repeat(100) { index -> BasicText("Item $index") } } UnstyledVerticalScrollbar(scrollbarState) { Thumb() } ``` ### Hiding the scrollbar while idle Use the `thumbVisibility` parameter to hide the thumb when the user is not interacting with the scrollable content: ```kotlin expandable UnstyledVerticalScrollbar(scrollbarState) { Thumb( thumbVisibility = ThumbVisibility.HideWhileIdle( enter = fadeIn(), exit = fadeOut(), hideDelay = 500.milliseconds, ), ) } ``` ### Supporting reverse layout Use the `reverseLayout` parameter when the scrollable content uses reverse layout: ```kotlin expandable UnstyledVerticalScrollbar( scrollbarState = scrollbarState, reverseLayout = true, ) { Thumb() } ``` ## API Reference ### rememberScrollbarState | Parameter | Type | Description | |-----------|------|-------------| | `scrollState` | `ScrollState` | | | `lazyListState` | `LazyListState` | | | `lazyGridState` | `LazyGridState` | | ### UnstyledVerticalScrollbar | Parameter | Type | Description | |-----------|------|-------------| | `scrollbarState` | `ScrollbarState` | | | `modifier` | `Modifier` | | | `enabled` | `Boolean` | | | `interactionSource` | `MutableInteractionSource?` | | | `reverseLayout` | `Boolean` | | | `thumb` | `(ScrollbarScope.() -> Unit)` | | ### UnstyledHorizontalScrollbar | Parameter | Type | Description | |-----------|------|-------------| | `scrollbarState` | `ScrollbarState` | | | `modifier` | `Modifier` | | | `enabled` | `Boolean` | | | `interactionSource` | `MutableInteractionSource?` | | | `reverseLayout` | `Boolean` | | | `thumb` | `(ScrollbarScope.() -> Unit)` | | ### ScrollbarScope.Thumb | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | | | `thumbVisibility` | `ThumbVisibility` | | | `enabled` | `Boolean` | | --- --- title: Separators description: Horizontal and vertical separators with caller-defined color and thickness. --- ```kotlin expandable title="SeparatorsDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/separators/SeparatorsDemo.kt" import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.Text import com.composeunstyled.UnstyledHorizontalSeparator import com.composeunstyled.UnstyledVerticalSeparator import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.inputBackgroundToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme @Preview @Composable fun SeparatorsDemo() { BoxWithConstraints( modifier = Modifier .fillMaxSize(), contentAlignment = Alignment.Center, ) { Column( Modifier .clip(RectangleShape) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken], RectangleShape) .width(240.dp), ) { Text( "New Window", modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), ) UnstyledHorizontalSeparator(Theme[colors][inputBackgroundToken]) Text("New Tab", Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) UnstyledHorizontalSeparator(Theme[colors][inputBackgroundToken]) Text( "New Incognito Tab", Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), ) UnstyledHorizontalSeparator(Theme[colors][inputBackgroundToken]) Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { Text( "Copy", modifier = Modifier.padding(8.dp).weight(1f), textAlign = TextAlign.Center, ) UnstyledVerticalSeparator(Theme[colors][inputBackgroundToken]) Text( "Cut", modifier = Modifier.padding(8.dp).weight(1f), textAlign = TextAlign.Center, ) UnstyledVerticalSeparator(Theme[colors][inputBackgroundToken]) Text( "Paste", Modifier.padding(8.dp).weight(1f), textAlign = TextAlign.Center, ) } } } } ``` ## Installation ```kotlin implementation("com.composables:composeunstyled-separators:2.10.0") ``` ## Anatomy ```kotlin UnstyledHorizontalSeparator(color = Color.Black) UnstyledVerticalSeparator(color = Color.Black) ``` ## Concepts - `UnstyledHorizontalSeparator` renders a horizontal line. - `UnstyledVerticalSeparator` renders a vertical line. ## Code Examples ### Changing separator thickness Use the `thickness` parameter to change the line thickness: ```kotlin expandable UnstyledHorizontalSeparator( color = Color.Black, thickness = 2.dp, ) ``` ## API Reference ### UnstyledHorizontalSeparator | Parameter | Type | Description | |-----------|------|-------------| | `color` | `Color` | the `Color` of the separator. | | `modifier` | `Modifier` | the `Modifier` to be used to this separator. | | `thickness` | `Dp` | a `Dp` of how thick the separator should rendered. | ### UnstyledVerticalSeparator | Parameter | Type | Description | |-----------|------|-------------| | `color` | `Color` | the `Color` of the separator. | | `modifier` | `Modifier` | the `Modifier` to be used to this separator. | | `thickness` | `Dp` | a `Dp` of how thick the separator should rendered. | --- --- title: Slider description: A slider component with custom track and thumb slots. --- ```kotlin expandable title="SliderDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/slider/SliderDemo.kt" import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.UnstyledSlider import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.theme.Theme @Preview @Composable fun SliderDemo() { val pillShape = RoundedCornerShape(100) Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() val isPressed by interactionSource.collectIsPressedAsState() var value by remember { mutableFloatStateOf(0.7f) } Box( modifier = Modifier .padding(horizontal = 16.dp) .widthIn(max = 480.dp) .fillMaxWidth(), ) { UnstyledSlider( interactionSource = interactionSource, value = value, onValueChange = { value = it }, modifier = Modifier.fillMaxWidth(), track = { state -> Box( Modifier .fillMaxWidth() .height(8.dp) .padding(horizontal = 16.dp) .clip(pillShape), ) { // the 'not yet completed' part of the track Box( Modifier .fillMaxHeight() .fillMaxWidth() .background(Theme[colors][borderToken]), ) // the 'completed' part of the track Box( Modifier .fillMaxHeight() .fillMaxWidth(state.fraction) .background(Theme[colors][contentToken]), ) } }, 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) { Theme[colors][contentToken].copy(0.16f) } 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, ) { Box( modifier = Modifier .size(thumbSize) .hoverable(thumbInteractionSource) .clip(CircleShape) .background(Theme[colors][contentToken]), ) } }, ) } } } ``` ## Features - Horizontal and vertical sliders - Discrete step support - Custom track and thumb slots - Keyboard and screen reader value changes ## Installation ```kotlin implementation("com.composables:composeunstyled-slider:2.10.0") ``` ## Anatomy ```kotlin UnstyledSlider( value = value, onValueChange = onValueChange, track = { }, thumb = { }, ) ``` ## Concepts - `UnstyledSlider` represents the interactive range users can drag or adjust. - `SliderState` is passed to the track and thumb slots. - The `track` slot renders below the thumb. - The `thumb` slot renders at the current slider offset. ## Accessibility `UnstyledSlider` exposes progress semantics and supports arrow keys, Page Up, Page Down, Home, and End. ## Code Examples ### Creating a stepped slider Use the `steps` parameter to snap the slider value to discrete stops: ```kotlin expandable UnstyledSlider( value = value, onValueChange = { value = it }, steps = 4, track = { state -> BasicText("${state.value}") }, thumb = { state -> BasicText("${state.value}") }, ) ``` ### Using a custom value range Use the `valueRange` parameter when the slider value is not from `0f` to `1f`: ```kotlin expandable UnstyledSlider( value = volume, onValueChange = { volume = it }, valueRange = 0f..100f, track = { state -> BasicText("${state.value}") }, thumb = { state -> BasicText("${state.value}") }, ) ``` ### Creating a vertical slider Use the `orientation` parameter to make the slider vertical: ```kotlin expandable UnstyledSlider( value = value, onValueChange = { value = it }, orientation = Orientation.Vertical, track = { state -> BasicText("${state.value}") }, thumb = { state -> BasicText("${state.value}") }, ) ``` ### Running code after value changes finish Use the `onValueChangeFinished` callback to react after drag, tap, keyboard, or screen reader changes finish: ```kotlin expandable UnstyledSlider( value = value, onValueChange = { value = it }, onValueChangeFinished = { save(value) }, track = { state -> BasicText("${state.value}") }, thumb = { state -> BasicText("${state.value}") }, ) ``` ## API Reference ### SliderState | Parameter | Type | Description | |-----------|------|-------------| | `valueRange` | `ClosedFloatingPointRange` | The range of values the slider can take. | | `steps` | `Int` | The number of discrete steps in the slider. | | `enabled` | `Boolean` | Whether the slider can receive user input. | | `orientation` | `Orientation` | Horizontal or vertical slider orientation. | | `isRtl` | `Boolean` | | | `isDragging` | `Boolean` | | | `isPressed` | `Boolean` | | | `isFocused` | `Boolean` | | | `tickFractions` | `FloatArray` | | | `value` | `Float` | The current value of the slider. | | `fraction` | `Float` | | ### UnstyledSlider | Parameter | Type | Description | |-----------|------|-------------| | `value` | `Float` | | | `onValueChange` | `(Float) -> Unit` | Callback invoked when the user changes the value. | | `modifier` | `Modifier` | Modifier to be applied to the slider. | | `enabled` | `Boolean` | Whether the slider can receive user input. | | `interactionSource` | `MutableInteractionSource?` | Interaction source for press, focus, and drag interactions. | | `valueRange` | `ClosedFloatingPointRange` | | | `steps` | `Int` | | | `onValueChangeFinished` | `(() -> Unit)?` | | | `orientation` | `Orientation` | Horizontal or vertical slider orientation. | | `reverseDirection` | `Boolean` | Whether to reverse the visual and input direction. | | `track` | `(SliderState) -> Unit` | Composable function to define the track of the slider. | | `thumb` | `(SliderState) -> Unit` | Composable function to define the thumb of the slider. | --- --- title: Tab Group description: A tab group component with generic tab keys and keyboard navigation. --- ```kotlin expandable title="TabGroupDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/tabgroup/TabGroupDemo.kt" import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composeunstyled.Text import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledTab import com.composeunstyled.UnstyledTabGroup import com.composeunstyled.UnstyledTabList import com.composeunstyled.UnstyledTabPanel import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.mutedContentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme @Preview @Composable fun TabGroupDemo() { class Article(val title: String, val relativeTime: String, val comments: Int, val points: Int) val categories = mapOf( "Trending" to listOf( Article( title = "I hosted my startup's backend on a Tamagotchi – AMA", relativeTime = "11 hours ago", comments = 312, points = 1042, ), Article( title = "I fired myself to improve company culture — it worked", relativeTime = "9 hours ago", comments = 264, points = 928, ), ), "Latest" to listOf( Article( title = "The office microwave is now a Kubernetes node", relativeTime = "2 hours ago", comments = 87, points = 356, ), Article( title = "We replaced scrum with interpretive dancing", relativeTime = "1 hour ago", comments = 52, points = 198, ), ), "Popular" to listOf( Article( title = "Social network for ants is growing fast", relativeTime = "14 hours ago", comments = 412, points = 1376, ), Article( title = "Why I quit my $800K FAANG job to grow mushrooms", relativeTime = "16 hours ago", comments = 391, points = 1204, ), ), ) var selectedTab by remember { mutableStateOf(categories.keys.first()) } Box( modifier = Modifier.fillMaxSize() .padding(16.dp) .padding(top = 90.dp), contentAlignment = Alignment.TopCenter, ) { UnstyledTabGroup( selectedTab = selectedTab, onSelectedTabChange = { selectedTab = it }, tabs = categories.keys.toList(), modifier = Modifier.widthIn(max = 450.dp), ) { Column { UnstyledTabList( modifier = Modifier .fillMaxWidth() .height(48.dp) .clip(RectangleShape) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken], RectangleShape), ) { Row(Modifier.fillMaxSize()) { categories.forEach { (key, _) -> UnstyledTab( key = key, modifier = Modifier.weight(1f).fillMaxHeight(), indication = LocalIndication.current, ) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text( text = key, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, color = if (selected) { Theme[colors][contentToken] } else { Theme[colors][mutedContentToken] }, ) if (selected) { Box( modifier = Modifier .background( color = Theme[colors][contentToken], shape = RectangleShape, ) .fillMaxWidth() .height(3.dp) .align(Alignment.BottomCenter), ) } } } } } } Spacer(modifier = Modifier.height(16.dp)) categories.forEach { (key, items) -> UnstyledTabPanel( key = key, modifier = Modifier .fillMaxWidth() .background( color = Theme[colors][surfaceToken], shape = RectangleShape, ) .border(1.dp, Theme[colors][borderToken], RectangleShape), ) { Column(Modifier.padding(16.dp)) { items.forEach { item -> UnstyledButton( onClick = { /* TODO */ }, modifier = Modifier.clip(RectangleShape), indication = LocalIndication.current, ) { Column(Modifier.padding(12.dp)) { Text( item.title, fontWeight = FontWeight.Medium, ) Spacer(Modifier.height(4.dp)) Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().alpha(0.6f), ) { Text(item.relativeTime) Text("·") Text("${item.comments} comments") Text("·") Text("${item.points} shares") } } } } } } } } } } } ``` ## Features - Generic tab keys - Horizontal and vertical tab lists - Automatic or manual activation - Focus handoff to panels ## Installation ```kotlin implementation("com.composables:composeunstyled-tab-group:2.10.0") ``` ## Anatomy ```kotlin val tabs = listOf("account", "billing") UnstyledTabGroup( selectedTab = selectedTab, onSelectedTabChange = onSelectedTabChange, tabs = tabs, ) { TabList { tabs.forEach { tab -> Tab(tab) { } } } tabs.forEach { tab -> TabPanel(tab) { } } } ``` ## Concepts - `UnstyledTabGroup` represents a set of tabs and panels grouped by key. - `TabList` renders the group of tabs. - `Tab` renders one tab inside `TabList`. - `TabPanel` renders the panel for the selected tab key. ## Accessibility Keep `tabs` in the same order as the visual tabs. `TabList` uses that order for arrow-key navigation, Home, and End. ## Code Examples ### Selecting tabs manually Use the `selectedTab` parameter to control the active tab: ```kotlin expandable val tabs = listOf("account", "billing") var selectedTab by remember { mutableStateOf("account") } UnstyledTabGroup( selectedTab = selectedTab, onSelectedTabChange = { selectedTab = it }, tabs = tabs, ) { TabList { tabs.forEach { tab -> Tab(tab) { BasicText(tab) } } } TabPanel("account") { BasicText("Account") } TabPanel("billing") { BasicText("Billing") } } ``` ### Creating vertical tabs Use the `orientation` parameter on `TabList` to change arrow-key navigation for vertical tabs: ```kotlin expandable TabList(orientation = Orientation.Vertical) { tabs.forEach { tab -> Tab(tab) { BasicText(tab) } } } ``` ### Requiring click activation Use the `activateOnFocus` parameter when arrow-key focus should not select tabs: ```kotlin expandable Tab("billing", activateOnFocus = false) { BasicText("Billing") } ``` ### Disabling a tab Use the `enabled` parameter to keep a tab visible but unavailable: ```kotlin expandable Tab( key = "billing", enabled = false, ) { BasicText("Billing") } ``` ## API Reference ### UnstyledTabGroup | Parameter | Type | Description | |-----------|------|-------------| | `selectedTab` | `T` | The initial selected tab for the tab group state. | | `onSelectedTabChange` | `(T) -> Unit` | | | `tabs` | `List` | | | `modifier` | `Modifier` | Modifier to be applied to the tab. | | `content` | `TabGroupScope.() -> Unit` | Composable function to define the content of the tab panel. | ### TabGroupScope.TabList | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Modifier to be applied to the tab. | | `orientation` | `Orientation` | The orientation of the tab list (horizontal or vertical). | | `content` | `TabListScope.() -> Unit` | Composable function to define the content of the tab panel. | ### TabListScope.Tab | Parameter | Type | Description | |-----------|------|-------------| | `key` | `T` | The unique key for the tab panel. | | `modifier` | `Modifier` | Modifier to be applied to the tab. | | `enabled` | `Boolean` | Whether the tab is enabled. | | `activateOnFocus` | `Boolean` | Whether to activate a tab when it receives focus. | | `indication` | `Indication?` | Visual indication for interactions. | | `interactionSource` | `MutableInteractionSource?` | Interaction source for the tab. | | `contentAlignment` | `Alignment` | | | `content` | `TabScope.() -> Unit` | Composable function to define the content of the tab panel. | ### TabGroupScope.TabPanel | Parameter | Type | Description | |-----------|------|-------------| | `key` | `T` | The unique key for the tab panel. | | `modifier` | `Modifier` | Modifier to be applied to the tab. | | `content` | `() -> Unit` | Composable function to define the content of the tab panel. | --- --- title: Text Field description: A text field component with placeholders, transformations, and text styling hooks. --- ```kotlin expandable title="TextFieldDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/textfield/TextFieldDemo.kt" import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.composeunstyled.LocalTextStyle import com.composeunstyled.Text import com.composeunstyled.TextInput import com.composeunstyled.UnstyledTextField import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme @Preview @Composable fun TextFieldDemo() { val displayName = rememberTextFieldState() Box( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp) .padding(top = 16.dp) .imePadding(), contentAlignment = Alignment.Center, ) { Box( modifier = Modifier .widthIn(max = 500.dp), contentAlignment = Alignment.Center, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, ) { UnstyledTextField( state = displayName, modifier = Modifier.fillMaxWidth(), lineLimits = TextFieldLineLimits.SingleLine, cursorBrush = SolidColor(Theme[colors][contentToken]), textStyle = LocalTextStyle.current.copy( color = Theme[colors][contentToken], fontSize = 14.sp, lineHeight = 20.sp, ), ) { Column { Text( "Display Name", modifier = Modifier.padding(bottom = 8.dp), color = Theme[colors][contentToken], fontSize = 16.sp, lineHeight = 24.sp, ) TextInput( Modifier .fillMaxWidth() .background(Theme[colors][surfaceToken], RectangleShape) .border(1.dp, Theme[colors][borderToken], RectangleShape) .padding(horizontal = 16.dp, vertical = 12.dp), placeholder = { Text( "Alex", color = Theme[colors][contentToken].copy(0.6f), fontSize = 14.sp, lineHeight = 20.sp, ) }, ) } } } } } } ``` ## Features - State-based text input - Placeholder slot - Input and output transformations - Text and selection style parameters ## Installation ```kotlin implementation("com.composables:composeunstyled-text-field:2.10.0") ``` ## Anatomy ```kotlin UnstyledTextField(state = state) { TextInput() } ``` ## Concepts - `UnstyledTextField` represents the text field container. - `TextInput` renders the editable text and optional placeholder. ## Accessibility Use `accessibilityLabel` when the text field does not include a readable label in its content. ## Code Examples ### Adding placeholder text Use the `placeholder` parameter on `TextInput` to render content while the field is empty: ```kotlin expandable val state = rememberTextFieldState() UnstyledTextField(state = state) { TextInput( placeholder = { BasicText("Email") }, ) } ``` ### Creating a single-line text field Use the `lineLimits` parameter to restrict input to one line: ```kotlin expandable UnstyledTextField( state = state, lineLimits = TextFieldLineLimits.SingleLine, ) { TextInput() } ``` ### Setting the keyboard type Use the `keyboardOptions` parameter to request a specific software keyboard: ```kotlin expandable UnstyledTextField( state = state, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), ) { TextInput() } ``` ### Styling entered text Use the text style parameters to set the style of the editable text: ```kotlin expandable UnstyledTextField( state = state, fontWeight = FontWeight.Medium, textColor = Color.Black, ) { TextInput() } ``` ### Styling selected text By default, selection colors are unspecified. Use the `selectionColors` parameter to set the selection handle and background colors: ```kotlin expandable UnstyledTextField( state = state, selectionColors = TextSelectionColors( handleColor = Color.Black, backgroundColor = Color.Black.copy(alpha = 0.4f), ), ) { TextInput() } ``` ### Labeling an icon-only text field Use the `accessibilityLabel` parameter when the visible text field has no text label: ```kotlin expandable UnstyledTextField( state = state, accessibilityLabel = "Search", ) { TextInput() } ``` ## API Reference ### UnstyledTextField | Parameter | Type | Description | |-----------|------|-------------| | `state` | `TextFieldState` | The `TextFieldState` that manages the text field's content. | | `modifier` | `Modifier` | Modifier to be applied to the text input. | | `enabled` | `Boolean` | | | `accessibilityLabel` | `String?` | | | `readOnly` | `Boolean` | | | `cursorBrush` | `Brush` | The brush to use for the cursor. | | `selectionColors` | `TextSelectionColors` | Colors to use for text selection handles and background. Defaults to unspecified colors. | | `textStyle` | `TextStyle` | Style to apply to the text. | | `textAlign` | `TextAlign` | Alignment of the text. | | `lineHeight` | `TextUnit` | Height of each line of text. | | `fontSize` | `TextUnit` | Size of the font. | | `letterSpacing` | `TextUnit` | Spacing between letters. | | `fontWeight` | `FontWeight?` | Weight of the font. | | `fontFamily` | `FontFamily?` | Family of the font. | | `textDecoration` | `TextDecoration?` | | | `lineLimits` | `TextFieldLineLimits` | | | `inputTransformation` | `InputTransformation?` | | | `outputTransformation` | `OutputTransformation?` | | | `onTextLayout` | `(Density.(getResult: () -> TextLayoutResult?) -> Unit)?` | | | `onKeyboardAction` | `KeyboardActionHandler?` | Handler for keyboard actions. | | `keyboardOptions` | `KeyboardOptions` | Options for the keyboard. | | `interactionSource` | `MutableInteractionSource?` | Interaction source for the text field. | | `textColor` | `Color` | Color of the text. | | `scrollState` | `ScrollState` | Scroll state for the text field. | | `content` | `TextFieldScope.() -> Unit` | Content composable that defines the text field's appearance. | ### TextFieldScope.TextInput | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Modifier to be applied to the text input. | | `placeholder` | `(() -> Unit)?` | Placeholder composable when the field is empty. | --- --- title: Toggle Switch description: A switch component with an animated thumb slot. --- ```kotlin expandable title="ToggleSwitchDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/toggleswitch/ToggleSwitchDemo.kt" import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.composeunstyled.Text import com.composeunstyled.Thumb import com.composeunstyled.Track import com.composeunstyled.UnstyledSwitch import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.inputBackgroundToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme @Preview @Composable fun ToggleSwitchDemo() { var toggled by remember { mutableStateOf(true) } val backgroundColor by animateColorAsState( if (toggled) Theme[colors][contentToken] else Theme[colors][inputBackgroundToken], ) val pillShape = RoundedCornerShape(100) Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { UnstyledSwitch( checked = toggled, onCheckedChange = { toggled = it }, modifier = Modifier .width(300.dp) .clip(RectangleShape), indication = LocalIndication.current, ) { Row( modifier = Modifier .fillMaxWidth() .padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text("Airplane Mode", fontSize = 18.sp) Track( modifier = Modifier .width(58.dp) .height(32.dp) .clip(pillShape) .background(backgroundColor, pillShape) .border(1.dp, Theme[colors][borderToken], pillShape), ) { Thumb( animationSpec = tween(), modifier = Modifier .padding(4.dp) .clip(CircleShape) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken], CircleShape) .size(24.dp), ) } } } } } ``` ## Installation ```kotlin implementation("com.composables:composeunstyled-toggle-switch:2.10.0") ``` ## Anatomy ```kotlin UnstyledSwitch( checked = checked, onCheckedChange = onCheckedChange, ) { SwitchThumb { } } ``` ## Concepts - `UnstyledSwitch` represents the interactive switch. - `SwitchThumb` places thumb content at the start or end of the switch layout. ## Accessibility Use the `onCheckedChange` parameter to make the switch interactive. Set it to `null` only when an accessible parent component owns the toggle interaction. ## Code Examples ### Toggling a switch Use the `checked` and `onCheckedChange` parameters to control switch state: ```kotlin expandable var checked by remember { mutableStateOf(false) } UnstyledSwitch( checked = checked, onCheckedChange = { checked = it }, ) { SwitchThumb { BasicText(if (checked) "On" else "Off") } } ``` ### Animating the switch thumb Use the `animationSpec` parameter on `SwitchThumb` to change the thumb animation: ```kotlin expandable UnstyledSwitch( checked = checked, onCheckedChange = { checked = it }, ) { SwitchThumb(animationSpec = tween(durationMillis = 200)) { BasicText(if (checked) "On" else "Off") } } ``` ### Moving toggle behavior to a parent Use the `onCheckedChange` parameter with `null` when a parent toggleable surface owns the interaction. This is useful when the switch is only the visual control inside a larger row. ```kotlin expandable Row( modifier = Modifier.toggleable( value = checked, role = Role.Switch, onValueChange = { checked = it }, ), ) { BasicText("Notifications") UnstyledSwitch( checked = checked, onCheckedChange = null, ) { SwitchThumb { BasicText(if (checked) "On" else "Off") } } } ``` ## API Reference ### UnstyledSwitch | Parameter | Type | Description | |-----------|------|-------------| | `checked` | `Boolean` | Whether the switch is on or off. | | `onCheckedChange` | `((Boolean) -> Unit)?` | Callback when the switch changes state. Pass `null` when another control owns the interaction. | | `modifier` | `Modifier` | Modifier to be applied to the switch. | | `enabled` | `Boolean` | Whether the switch is enabled. | | `interactionSource` | `MutableInteractionSource?` | Interaction source for press, focus, and drag interactions. | | `indication` | `Indication?` | Indication used for the switch interaction. | | `accessibilityLabel` | `String?` | | | `content` | `SwitchScope.() -> Unit` | Content drawn inside the switch scope. | ### SwitchScope | Parameter | Type | Description | |-----------|------|-------------| | `checked` | `Boolean` | Whether the switch is on or off. | | `enabled` | `Boolean` | Whether the switch is enabled. | | `interactionSource` | `MutableInteractionSource` | Interaction source for press, focus, and drag interactions. | ### SwitchScope.SwitchThumb | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Modifier to apply to the thumb container. | | `animationSpec` | `FiniteAnimationSpec` | Animation used when the thumb moves between states. | | `content` | `() -> Unit` | Thumb content. | --- --- title: Tooltip description: A tooltip component for contextual help on hover, focus, and long press. --- ```kotlin expandable title="TooltipDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/tooltip/TooltipDemo.kt" import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.slideInVertically import androidx.compose.foundation.Canvas import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.composables.icons.lucide.BellDot import com.composables.icons.lucide.Lucide import com.composeunstyled.AnchorAlignment import com.composeunstyled.AnchorSide import com.composeunstyled.Text import com.composeunstyled.TooltipHost import com.composeunstyled.TooltipPanel import com.composeunstyled.TooltipPlacement import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledIcon import com.composeunstyled.UnstyledTooltip import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.focusRingToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.focusRing import com.composeunstyled.theme.Theme @Preview @Composable fun TooltipDemo() { TooltipHost(Modifier.fillMaxSize()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { UnstyledTooltip( side = AnchorSide.Top, alignment = AnchorAlignment.Center, panel = { TooltipPanel( enter = slideInVertically(tween(150), initialOffsetY = { (it * 0.25).toInt() }) + scaleIn( animationSpec = tween(150), transformOrigin = TransformOrigin(0.5f, 1f), initialScale = 0.65f, ) + fadeIn(tween(150)), exit = fadeOut(tween(250)), ) { TooltipBubble(it) } }, ) { val interactionSource = remember { MutableInteractionSource() } UnstyledButton( onClick = { }, modifier = Modifier .clip(CircleShape) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken], CircleShape) .focusRing(interactionSource, 1.dp, Theme[colors][focusRingToken], CircleShape), interactionSource = interactionSource, indication = LocalIndication.current, ) { Box(Modifier.padding(8.dp)) { UnstyledIcon( imageVector = Lucide.BellDot, contentDescription = null, tint = Theme[colors][contentToken], ) } } } } } } @Composable private fun TooltipBubble(placement: TooltipPlacement) { when (placement.side) { AnchorSide.Top -> Column(horizontalAlignment = Alignment.CenterHorizontally) { TooltipContainer() TooltipArrow(placement) } AnchorSide.Bottom -> Column(horizontalAlignment = Alignment.CenterHorizontally) { TooltipArrow(placement) TooltipContainer() } AnchorSide.Start -> Row(verticalAlignment = Alignment.CenterVertically) { TooltipContainer() TooltipArrow(placement) } AnchorSide.End -> Row(verticalAlignment = Alignment.CenterVertically) { TooltipArrow(placement) TooltipContainer() } } } @Composable private fun TooltipContainer() { Box( modifier = Modifier .clip(RoundedCornerShape(100)) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken], RoundedCornerShape(100)) .padding(vertical = 8.dp, horizontal = 12.dp), ) { Text("Notifications", color = Theme[colors][contentToken]) } } @Composable private fun TooltipArrow(placement: TooltipPlacement) { val arrowOffset = placement.positionAdjustment val modifier = when (placement.side) { AnchorSide.Top, AnchorSide.Bottom, -> Modifier.offset { IntOffset(-arrowOffset.x, 0) } AnchorSide.Start, AnchorSide.End, -> Modifier.offset { IntOffset(0, -arrowOffset.y) } } val degrees = when (placement.side) { AnchorSide.Top -> 180f AnchorSide.Bottom -> 0f AnchorSide.Start -> 270f AnchorSide.End -> 90f } ArrowUp(modifier.rotate(degrees), Theme[colors][borderToken]) } @Composable private fun ArrowUp(modifier: Modifier = Modifier, color: Color) { Canvas(modifier = modifier.size(8.dp, 4.dp)) { val path = Path().apply { moveTo(size.width / 2f, 0f) lineTo(0f, size.height) lineTo(size.width, size.height) close() } drawPath(path, color = color) } } ``` ## Features - Focus, hover, and long-press triggers - Non-modal tooltip panel - Collision-aware positioning - Screen reader announcements ## Installation ```kotlin implementation("com.composables:composeunstyled-tooltip:2.10.0") ``` ## Anatomy ```kotlin TooltipHost { UnstyledTooltip( panel = { TooltipPanel { } }, ) { } } ``` ## Concepts - `TooltipHost` provides the destination where tooltip panels are rendered. - `UnstyledTooltip` marks the interactive area the user can hover, focus, or long-press to show the tooltip. - The `anchor` slot renders the content on which the tooltip will be anchored to. - The `panel` slot renders the floating content that is shown for the anchor. - `TooltipPanel` renders the floating tooltip content. ## Usage Considerations `UnstyledTooltip` does not make the anchor focusable. Use focusable content in the anchor slot when the tooltip needs to show on keyboard focus. ## Accessibility `TooltipPanel` automatically announces the tooltip content when the tooltip becomes visible. ## Code Examples ### Positioning a tooltip Use the `side`, `alignment`, `sideOffset`, and `alignmentOffset` parameters to place the tooltip relative to the anchor: ```kotlin expandable UnstyledTooltip( side = AnchorSide.Bottom, alignment = AnchorAlignment.Center, sideOffset = 8.dp, panel = { TooltipPanel { BasicText("More information") } }, ) { BasicText("Help") } ``` ### Delaying tooltip hover Use the `hoverDelayMillis` parameter to wait before showing the tooltip on hover: ```kotlin expandable UnstyledTooltip( hoverDelayMillis = 500, panel = { TooltipPanel { BasicText("More information") } }, ) { BasicText("Help") } ``` ### Changing the long-press duration Use the `longPressShowDurationMillis` parameter to change how long the tooltip stays visible after a long press: ```kotlin expandable UnstyledTooltip( longPressShowDurationMillis = 3_000, panel = { TooltipPanel { BasicText("More information") } }, ) { BasicText("Help") } ``` ### Animating a tooltip panel Use the `enter` and `exit` parameters on `TooltipPanel` to animate the tooltip panel: ```kotlin expandable UnstyledTooltip( panel = { TooltipPanel( enter = fadeIn(), exit = fadeOut(), ) { BasicText("More information") } }, ) { BasicText("Help") } ``` ## API Reference ### TooltipHost | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Modifier to be applied to the tooltip host. | | `content` | `() -> Unit` | Content that can render tooltip panels into this host. | ### UnstyledTooltip | Parameter | Type | Description | |-----------|------|-------------| | `enabled` | `Boolean` | Whether the tooltip is enabled. When disabled, the tooltip will not show. | | `panel` | `TooltipScope.() -> Unit` | A composable function that defines the tooltip content panel. | | `side` | `AnchorSide` | | | `alignment` | `AnchorAlignment` | | | `sideOffset` | `Dp` | | | `alignmentOffset` | `Dp` | | | `longPressShowDurationMillis` | `Long` | Duration in milliseconds to show the tooltip after a long press. Default is 1500ms. | | `hoverDelayMillis` | `Long` | Delay in milliseconds before showing the tooltip on hover. Default is 0ms. | | `anchor` | `() -> Unit` | A composable function that defines the anchor element that triggers the tooltip. | ### TooltipPlacement | Parameter | Type | Description | |-----------|------|-------------| | `side` | `AnchorSide` | | | `alignment` | `AnchorAlignment` | | | `positionAdjustment` | `IntOffset` | | ### TooltipScope.TooltipPanel | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Modifier to be applied to the tooltip panel. | | `enter` | `EnterTransition` | The enter transition for the tooltip panel. Default is instant appearance. | | `exit` | `ExitTransition` | The exit transition for the tooltip panel. Default is instant disappearance. | | `content` | `(TooltipPlacement) -> Unit` | A composable function that defines the content of the tooltip. | --- --- title: ColoredIndication description: A customizable indication effect that displays colored overlays based on user interactions like hover, press, focus, and drag. --- ## API Reference ### rememberColoredIndication | Parameter | Type | Description | |-----------|------|-------------| | `hoveredColor` | `Color` | Color overlay to display when the component is hovered | | `pressedColor` | `Color` | Color overlay to display when the component is pressed | | `focusedColor` | `Color` | Color overlay to display when the component is focused | | `draggedColor` | `Color` | Color overlay to display when the component is being dragged | | `showAnimationSpec` | `AnimationSpec` | | | `hideAnimationSpec` | `AnimationSpec` | | | `color` | `Color` | | | `draggedAlpha` | `Float` | | | `focusedAlpha` | `Float` | | | `hoveredAlpha` | `Float` | | | `pressedAlpha` | `Float` | | ### ColoredIndication | Parameter | Type | Description | |-----------|------|-------------| | `hoveredColor` | `Color` | | | `pressedColor` | `Color` | | | `focusedColor` | `Color` | | | `draggedColor` | `Color` | | | `animationSpecEnter` | `AnimationSpec` | | | `animationSpecExit` | `AnimationSpec` | | ## Installation ```kotlin implementation("com.composables:composeunstyled-colored-indication:2.10.0") ``` ## Code Examples ### Basic Usage Use `rememberColoredIndication` to create a colored indication effect with a single base color: ```kotlin expandable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import com.composeunstyled.UnstyledButton import androidx.compose.foundation.text.BasicText import com.composeunstyled.theme.rememberColoredIndication @Composable fun ColoredIndicationBasicExample() { val interactionSource = remember { MutableInteractionSource() } UnstyledButton( onClick = { }, backgroundColor = Color(0xFF3B82F6), shape = RoundedCornerShape(8.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), indication = rememberColoredIndication(color = Color.White), interactionSource = interactionSource ) { BasicText("Hover or Click", style = TextStyle(color = Color.White)) } } ``` ```kotlin expandable import com.composeunstyled.UnstyledButton import com.composeunstyled.theme.rememberColoredIndication ``` ```kotlin expandable UnstyledButton( onClick = { }, indication = rememberColoredIndication(color = Color.White), ) { BasicText("Hover or Click") } ``` --- --- title: focusRing description: A modifier that draws an outline around a composable's bounds when focus should be visibly indicated, similar to browser focus indicators. --- The `focusRing` is based on the [outline](https://composeunstyled.com/docs/styling/outline.md) modifier and does not affect layout or size - it draws purely outside the composable's bounds. By default it follows focus-visible behavior: keyboard focus shows the ring, while pointer-origin focus does not. ## API Reference ### FocusVisibilityProvider | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Modifier to be applied to the provider container. | | `content` | `() -> Unit` | The content that should share focus-visible input tracking. | ### Modifier.focusRing | Parameter | Type | Description | |-----------|------|-------------| | `interactionSource` | `InteractionSource` | The InteractionSource to subscribe to for focus events | | `width` | `Dp` | The thickness of the focus ring | | `color` | `Color` | The color of the focus ring | | `shape` | `Shape` | The shape of the composable (defaults to `RectangleShape`) | | `offset` | `Dp` | Distance between composable and ring (defaults to `0.dp`) | | `visibility` | `FocusRingVisibility` | Controls whether the ring appears for any focus or only focus-visible focus (defaults to `FocusRingVisibility.FocusVisible`) | ## Installation ```kotlin implementation("com.composables:composeunstyled-focus-ring:2.10.0") ``` ## Code Examples ### Basic Example Focus ring requires an `interactionSource`, `width` and `color` to render a focus indicator around the component. The `shape` parameter should match the shape of the composable you are styling for proper alignment. ```kotlin expandable val interactionSource = remember { MutableInteractionSource() } FocusVisibilityProvider { SimpleButton( modifier = Modifier.focusRing( interactionSource = interactionSource, width = 2.dp, color = Color(0xFF3B82F6), shape = RoundedCornerShape(8.dp), offset = 2.dp ), interactionSource = interactionSource ) } ``` ### Focus Visibility Wrap your app or screen content with `FocusVisibilityProvider` so Compose Unstyled can track whether the latest input came from the keyboard or a pointer. Programmatic focus and unknown input modes are treated conservatively and show the ring. Use `visibility = FocusRingVisibility.Focused` when you need the old behavior where any focused component shows the ring. ```kotlin expandable FocusVisibilityProvider { SimpleButton( modifier = Modifier.focusRing( interactionSource = interactionSource, width = 2.dp, color = Color(0xFF3B82F6), visibility = FocusRingVisibility.Focused ), interactionSource = interactionSource ) } ``` ### Customizing Width You can customize the thickness of the focus ring by adjusting the `width` parameter. ```kotlin expandable val interactionSource = remember { MutableInteractionSource() } SimpleButton( modifier = Modifier.focusRing(interactionSource, 1.dp, Color(0xFF3B82F6), shape = RoundedCornerShape(8.dp)), interactionSource = interactionSource ) SimpleButton( modifier = Modifier.focusRing(interactionSource, 2.dp, Color(0xFF3B82F6), shape = RoundedCornerShape(8.dp)), interactionSource = interactionSource ) SimpleButton( modifier = Modifier.focusRing(interactionSource, 4.dp, Color(0xFF3B82F6), shape = RoundedCornerShape(8.dp)), interactionSource = interactionSource ) ``` ### Customizing Shape The focus ring adapts to different shapes. The `shape` parameter should match the shape of your composable for proper alignment. Note that generic shapes are not supported and will fail silently. ```kotlin expandable val interactionSource = remember { MutableInteractionSource() } SimpleButton( shape = RectangleShape, modifier = Modifier.focusRing(interactionSource, 2.dp, Color(0xFF3B82F6), shape = RectangleShape), interactionSource = interactionSource ) SimpleButton( shape = RoundedCornerShape(8.dp), modifier = Modifier.focusRing(interactionSource, 2.dp, Color(0xFF3B82F6), shape = RoundedCornerShape(8.dp)), interactionSource = interactionSource ) SimpleButton( shape = RoundedCornerShape(100), modifier = Modifier.focusRing(interactionSource, 2.dp, Color(0xFF3B82F6), shape = RoundedCornerShape(100)), interactionSource = interactionSource ) ``` ### Customizing Offset The `offset` parameter controls the distance between the composable and its focus ring, creating a gap effect. ```kotlin expandable val interactionSource = remember { MutableInteractionSource() } SimpleButton( modifier = Modifier.focusRing(interactionSource, 2.dp, Color(0xFF3B82F6), offset = 0.dp, shape = RoundedCornerShape(8.dp)), interactionSource = interactionSource ) SimpleButton( modifier = Modifier.focusRing(interactionSource, 2.dp, Color(0xFF3B82F6), offset = 4.dp, shape = RoundedCornerShape(8.dp)), interactionSource = interactionSource ) SimpleButton( modifier = Modifier.focusRing(interactionSource, 2.dp, Color(0xFF3B82F6), offset = 8.dp, shape = RoundedCornerShape(8.dp)), interactionSource = interactionSource ) ``` ### Customizing Color You can customize the focus ring color to match your design system or create visual emphasis. ```kotlin expandable val redInteractionSource = remember { MutableInteractionSource() } val greenInteractionSource = remember { MutableInteractionSource() } val purpleInteractionSource = remember { MutableInteractionSource() } SimpleButton( modifier = Modifier.focusRing(redInteractionSource, 2.dp, Color(0xFFEF4444), offset = 2.dp, shape = RoundedCornerShape(8.dp)), interactionSource = redInteractionSource ) SimpleButton( modifier = Modifier.focusRing(greenInteractionSource, 2.dp, Color(0xFF10B981), offset = 2.dp, shape = RoundedCornerShape(8.dp)), interactionSource = greenInteractionSource ) SimpleButton( modifier = Modifier.focusRing(purpleInteractionSource, 2.dp, Color(0xFF8B5CF6), offset = 2.dp, shape = RoundedCornerShape(8.dp)), interactionSource = purpleInteractionSource ) ``` --- --- title: outline description: A modifier that draws an outline outside a composable's bounds, similar to CSS `outline` property. --- Unlike Compose's built-in `border` modifier, `outline` does not affect layout or size - it draws purely outside the composable's bounds. ## API Reference ### Modifier.outline | Parameter | Type | Description | |-----------|------|-------------| | `width` | `Dp` | The thickness of the outline | | `color` | `Color` | The color of the outline | | `shape` | `Shape` | The shape of the composable (defaults to `RectangleShape`) | | `offset` | `Dp` | Distance between composable and outline (defaults to `0.dp`) | ## Installation ```kotlin implementation("com.composables:composeunstyled-outline:2.10.0") ``` ## Code Examples ### Basic Example Outline requires a `width` and ` color` to render anything around the composable. The `shape` parameter accepts the shape of the composable you are styling. The final shape of the outline will be calculated based of the provided shape, width and offset. ```kotlin expandable SimpleButton( shape = RoundedCornerShape(8.dp), modifier = Modifier.outline( width = 2.dp, color = Color(0xFF3B82F6), shape = RoundedCornerShape(8.dp), ) ) ``` ### Customizing Width You can customize the thickness of the outline by adjusting the `width` parameter. ```kotlin expandable SimpleButton( modifier = Modifier.outline(1.dp, Color(0xFF3B82F6), shape = RoundedCornerShape(8.dp)) ) SimpleButton( modifier = Modifier.outline(2.dp, Color(0xFF3B82F6), shape = RoundedCornerShape(8.dp)) ) SimpleButton( modifier = Modifier.outline(4.dp, Color(0xFF3B82F6), shape = RoundedCornerShape(8.dp)) ) ``` ### Customizing Shape The outline adapts to different shapes. The `shape` parameter should match the shape of your composable for proper alignment. Note that generic shapes are not supported and will fail silently. ```kotlin expandable 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) ) ``` ### Customizing Offset The `offset` parameter controls the distance between the composable and its outline, creating a gap effect. ```kotlin expandable SimpleButton( modifier = Modifier.outline(2.dp, Color(0xFF3B82F6), offset = 0.dp, shape = RoundedCornerShape(8.dp)) ) SimpleButton( modifier = Modifier.outline(2.dp, Color(0xFF3B82F6), offset = 4.dp, shape = RoundedCornerShape(8.dp)) ) SimpleButton( modifier = Modifier.outline(2.dp, Color(0xFF3B82F6), offset = 8.dp, shape = RoundedCornerShape(8.dp)) ) ``` ### Customizing Color You can customize the outline color to match your design system or create visual emphasis. ```kotlin expandable SimpleButton( modifier = Modifier.outline(2.dp, Color(0xFFEF4444), offset = 2.dp, shape = RoundedCornerShape(8.dp)) ) SimpleButton( modifier = Modifier.outline(2.dp, Color(0xFF10B981), offset = 2.dp, shape = RoundedCornerShape(8.dp)) ) SimpleButton( modifier = Modifier.outline(2.dp, Color(0xFF8B5CF6), offset = 2.dp, shape = RoundedCornerShape(8.dp)) ) ``` --- --- title: Window Breakpoints description: Window breakpoint utilities for building adaptive Compose layouts. --- ```kotlin expandable title="BreakpointsDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/breakpoints/BreakpointsDemo.kt" import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.composables.uripainter.rememberUriPainter import com.composeunstyled.CrossAxisAlignment import com.composeunstyled.ProvideWindowWidthBreakpoints import com.composeunstyled.Stack import com.composeunstyled.StackOrientation import com.composeunstyled.Text import com.composeunstyled.WidthBreakpoint import com.composeunstyled.WindowWidthBreakpoints import com.composeunstyled.buildModifier import com.composeunstyled.currentWindowWidthBreakpoint import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.inputBackgroundToken import com.composeunstyled.demo.mutedContentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.theme.Theme private val Compact = WidthBreakpoint("compact") private val Medium = WidthBreakpoint("medium") private val Expanded = WidthBreakpoint("expanded") private val DemoWidthBreakpoints = WindowWidthBreakpoints { Compact startsAt 0.dp Medium startsAt 600.dp Expanded startsAt 840.dp } @Preview @Composable fun BreakpointsDemo() { ProvideWindowWidthBreakpoints(DemoWidthBreakpoints) { val widthBreakpoint = currentWindowWidthBreakpoint() val imagePainter = rememberUriPainter( "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee" + "?auto=format&fit=crop&w=1200&q=80", ) Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Stack( modifier = Modifier .widthIn(max = if (widthBreakpoint isAtLeast Expanded) 860.dp else 360.dp) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][contentToken]) .padding(14.dp), orientation = if (widthBreakpoint isAtLeast Expanded) { StackOrientation.Horizontal } else { StackOrientation.Vertical }, crossAxisAlignment = CrossAxisAlignment.Start, spacing = 18.dp, ) { Image( painter = imagePainter, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .background(Theme[colors][inputBackgroundToken]) then buildModifier { if (widthBreakpoint isAtLeast Expanded) { add(Modifier.size(width = 320.dp, height = 280.dp)) } else { add(Modifier.fillMaxWidth().height(280.dp)) } }, ) Stack( modifier = Modifier then buildModifier { if (widthBreakpoint isAtLeast Expanded) { add(Modifier.weight(1f)) } else { add(Modifier.fillMaxWidth()) } }, orientation = StackOrientation.Vertical, spacing = 12.dp, ) { Text( text = "Adaptive layouts", color = Theme[colors][contentToken], fontSize = 24.sp, lineHeight = 30.sp, ) Text( text = "This card switches from vertical to horizontal at ${Expanded.name}", color = Theme[colors][mutedContentToken], fontSize = 15.sp, lineHeight = 22.sp, ) } } } } } ``` ## Features - Define custom width and/or height breakpoints. - Build responsive layouts around named semantics instead of copy-pasting dimensions. - Emit new state only when the resolved breakpoint changes, not on every window resize. ## Installation ```kotlin implementation("com.composables:composeunstyled-breakpoints:2.10.0") ``` ## Anatomy ```kotlin val Compact = WidthBreakpoint("compact") val Medium = WidthBreakpoint("medium") val widthBreakpoints = WindowWidthBreakpoints { Compact startsAt 0.dp Medium startsAt 600.dp } ProvideWindowWidthBreakpoints(widthBreakpoints) { val widthBreakpoint = currentWindowWidthBreakpoint() } ``` ## Concepts - `WidthBreakpoint`/`HeightBreakpoint` define a named breakpoint. - `WindowWidthBreakpoints`/`WindowHeightBreakpoints` define the minimum window size where each breakpoint starts. - Every breakpoint scale must include one breakpoint that starts at `0.dp`. - `ProvideWindowWidthBreakpoints`/`ProvideWindowHeightBreakpoints` make one breakpoint scale available to their content scope. - `ProvideWindowBreakpoints` makes both width and height breakpoint scales available to its content scope. - `currentWindowWidthBreakpoint()`/`currentWindowHeightBreakpoint()` return a new resolved breakpoint only when the current window crosses into another breakpoint. ## Code Examples ### Building responsive layouts Define the window widths your layout should respond to using `WindowWidthBreakpoints`. Then use `ProvideWindowWidthBreakpoints` near the root of your app to make the current breakpoint available down the UI tree. Use `currentWindowWidthBreakpoint()` to access the currently resolved breakpoint: ```kotlin expandable val Compact = WidthBreakpoint("compact") val Medium = WidthBreakpoint("medium") val Expanded = WidthBreakpoint("expanded") val widthBreakpoints = WindowWidthBreakpoints { Compact startsAt 0.dp Medium startsAt 600.dp Expanded startsAt 840.dp } ProvideWindowWidthBreakpoints(widthBreakpoints) { val widthBreakpoint = currentWindowWidthBreakpoint() val expanded = widthBreakpoint isAtLeast Expanded if (expanded) { BasicText("Expanded layout") } else { BasicText("Compact layout") } } ``` ### Using height breakpoints Use `ProvideWindowHeightBreakpoints` with `currentWindowHeightBreakpoint()` when layout behavior depends on available height: ```kotlin expandable val Short = HeightBreakpoint("short") val Tall = HeightBreakpoint("tall") val heightBreakpoints = WindowHeightBreakpoints { Short startsAt 0.dp Tall startsAt 720.dp } ProvideWindowHeightBreakpoints(heightBreakpoints) { val heightBreakpoint = currentWindowHeightBreakpoint() val canShowDetails = heightBreakpoint isAtLeast Tall } ``` ## API Reference ### WidthBreakpoint | Parameter | Type | Description | |-----------|------|-------------| | `name` | `String` | | ### HeightBreakpoint | Parameter | Type | Description | |-----------|------|-------------| | `name` | `String` | | ### WindowWidthBreakpoints | Parameter | Type | Description | |-----------|------|-------------| | `content` | `WindowWidthBreakpointsBuilder.() -> Unit` | | ### WindowHeightBreakpoints | Parameter | Type | Description | |-----------|------|-------------| | `content` | `WindowHeightBreakpointsBuilder.() -> Unit` | | ### ResolvedWidthBreakpoint | Parameter | Type | Description | |-----------|------|-------------| | `value` | `WidthBreakpoint` | | | `name` | `String` | | ### ResolvedHeightBreakpoint | Parameter | Type | Description | |-----------|------|-------------| | `value` | `HeightBreakpoint` | | | `name` | `String` | | ### ProvideWindowBreakpoints | Parameter | Type | Description | |-----------|------|-------------| | `width` | `WindowWidthBreakpoints` | | | `height` | `WindowHeightBreakpoints` | | | `content` | `() -> Unit` | | ### ProvideWindowWidthBreakpoints | Parameter | Type | Description | |-----------|------|-------------| | `breakpoints` | `WindowWidthBreakpoints` | | | `content` | `() -> Unit` | | ### ProvideWindowHeightBreakpoints | Parameter | Type | Description | |-----------|------|-------------| | `breakpoints` | `WindowHeightBreakpoints` | | | `content` | `() -> Unit` | | ### currentWindowWidthBreakpoint | Parameter | Type | Description | |-----------|------|-------------| | `returns` | `ResolvedWidthBreakpoint` | | ### currentWindowHeightBreakpoint | Parameter | Type | Description | |-----------|------|-------------| | `returns` | `ResolvedHeightBreakpoint` | | --- --- title: Stack description: A single layout component for horizontal and vertical stacks. --- ## Installation ```kotlin implementation("com.composables:composeunstyled-stack:2.10.0") ``` ## Anatomy ```kotlin Stack { } ``` ## Concepts - `Stack` renders children in one horizontal or vertical layout. - The `weight()` modifier distributes remaining space in the current stack orientation. ## Code Examples ### Creating a vertical stack Use the `orientation` parameter to arrange content vertically: ```kotlin expandable Stack(orientation = StackOrientation.Vertical) { BasicText("One") BasicText("Two") } ``` ### Adding space between items Use the `spacing` parameter to add equal space between children: ```kotlin expandable Stack(spacing = 12.dp) { BasicText("One") BasicText("Two") } ``` ### Aligning stack content Use the `mainAxisArrangement` and `crossAxisAlignment` parameters to align children: ```kotlin expandable Stack( mainAxisArrangement = MainAxisArrangement.Center, crossAxisAlignment = CrossAxisAlignment.Center, ) { BasicText("One") BasicText("Two") } ``` ### Weighting stack children Use the `weight()` modifier inside `Stack` content to distribute remaining space: ```kotlin expandable Stack { BasicText( text = "Primary", modifier = Modifier.weight(1f), ) BasicText("Secondary") } ``` ## API Reference ### Stack | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | Modifier to be applied to the Stack | | `orientation` | `StackOrientation` | Controls whether children are laid out horizontally or vertically. Defaults to `StackOrientation.Horizontal` | | `mainAxisArrangement` | `MainAxisArrangement` | Controls the arrangement of children along the main axis (horizontal for horizontal orientation, vertical for vertical orientation). Defaults to `MainAxisArrangement.Start` | | `crossAxisAlignment` | `CrossAxisAlignment` | Controls the alignment of children on the cross axis (vertical for horizontal orientation, horizontal for vertical orientation). Defaults to `CrossAxisAlignment.Start` | | `spacing` | `Dp` | Space between children. Ignored when using `SpaceEvenly`, `SpaceBetween`, or `SpaceAround` arrangements. Defaults to `0.dp` | | `content` | `StackScope.() -> Unit` | The composable content to be laid out within the Stack | --- --- title: buildModifier description: A utility function that enables conditional chaining of modifiers using a builder pattern, providing a clean alternative to nested conditional statements or multiple modifier chains. --- ## API Reference ### buildModifier | Parameter | Type | Description | |-----------|------|-------------| | `builderAction` | `MutableList.() -> Unit` | | The function returns a single `Modifier` that is the result of chaining all the modifiers added to the list. ## Installation ```kotlin implementation("com.composables:composeunstyled-build-modifier:2.10.0") ``` ## Code Examples ### Basic Example The `buildModifier` function provides a clean way to conditionally chain modifiers without creating nested conditional statements. Instead of writing: ```kotlin expandable val isSelected by remember { mutableStateOf(true) } val isClickable by remember { mutableStateOf(false) } val modifier = Modifier.padding(16.dp) .let { if (isSelected) it.background(Color.Blue) else it } .let { if (isClickable) it.clickable { /* handle click */ } else it } ``` You can write: ```kotlin expandable val isSelected by remember { mutableStateOf(true) } val isClickable by remember { mutableStateOf(false) } val modifier = buildModifier { add(Modifier.padding(16.dp)) if (isSelected) { add(Modifier.background(Color.Blue)) } if (isClickable) { add(Modifier.clickable { /* handle click */ }) } } ``` ### Conditional Styling Build modifiers based on component state or external conditions. ```kotlin expandable val isError by remember { mutableStateOf(true) } val isDisabled by remember { mutableStateOf(false) } BasicText( text = "Form Field", modifier = buildModifier { add(Modifier.padding(12.dp)) if (isError) { add(Modifier.background(Color.Red.copy(alpha = 0.1f))) add(Modifier.border(1.dp, Color.Red, RoundedCornerShape(4.dp))) } if (isDisabled) { add(Modifier.alpha(0.5f)) } else { add(Modifier.clickable { /* handle click */ }) } } ) ``` ### Dynamic Sizing Handle optional size constraints elegantly. ```kotlin expandable val maxWidth by remember { mutableStateOf(300.dp) } val fixedHeight by remember { mutableStateOf(null) } val backgroundColor by remember { mutableStateOf(Color(0xFFF5F5F5)) } Card( modifier = buildModifier { add(Modifier.padding(16.dp)) maxWidth?.let { add(Modifier.widthIn(max = it)) } fixedHeight?.let { add(Modifier.height(it)) } add(Modifier.background(backgroundColor, RoundedCornerShape(8.dp))) } ) { // Card content } ``` ### Complex State Management Combine multiple conditions for sophisticated modifier logic. ```kotlin expandable val isLoading by remember { mutableStateOf(false) } val hasError by remember { mutableStateOf(true) } val isSelected by remember { mutableStateOf(false) } val isInteractive by remember { mutableStateOf(true) } Box( modifier = buildModifier { add(Modifier.fillMaxWidth().padding(8.dp)) // Base styling add(Modifier.background(Color.White, RoundedCornerShape(8.dp))) add(Modifier.border(1.dp, Color.Gray.copy(alpha = 0.3f), RoundedCornerShape(8.dp))) // State-specific modifications when { isLoading -> { add(Modifier.alpha(0.7f)) add(Modifier.shimmer()) // Custom shimmer effect } hasError -> { add(Modifier.border(2.dp, Color.Red, RoundedCornerShape(8.dp))) add(Modifier.background(Color.Red.copy(alpha = 0.05f), RoundedCornerShape(8.dp))) } isSelected -> { add(Modifier.border(2.dp, Color.Blue, RoundedCornerShape(8.dp))) add(Modifier.background(Color.Blue.copy(alpha = 0.1f), RoundedCornerShape(8.dp))) } } // Interactive behavior if (isInteractive && !isLoading) { add(Modifier.clickable { /* handle selection */ }) } } ) { // Box content } ``` --- --- title: Escape Handler description: A multiplatform handler for dismissing UI from Escape or Back interactions. --- ## Installation ```kotlin implementation("com.composables:composeunstyled-escape-handler:2.10.0") ``` ## Basic Example Use `EscapeHandler` when a visible surface should close from Escape or Back interactions. ```kotlin expandable var visible by remember { mutableStateOf(false) } if (visible) { EscapeHandler { visible = false } } ``` ## Usage `EscapeHandler` is useful for dismissible surfaces such as dialogs, modals, menus, and custom overlays. --- --- title: Modal description: A low-level modal layer for blocking background interaction. --- ```kotlin expandable title="ModalDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/modal/ModalDemo.kt" import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.Image import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PageSize import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.composables.icons.lucide.ArrowLeft import com.composables.icons.lucide.ArrowRight import com.composables.icons.lucide.Lucide import com.composables.uripainter.rememberUriPainter import com.composeunstyled.EscapeHandler import com.composeunstyled.Modal import com.composeunstyled.Scrim import com.composeunstyled.Text import com.composeunstyled.UnstyledButton import com.composeunstyled.UnstyledIcon import com.composeunstyled.demo.borderToken import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.mutedContentToken import com.composeunstyled.demo.surfaceToken import com.composeunstyled.rememberModalState import com.composeunstyled.theme.Theme import kotlinx.coroutines.launch @Preview @Composable fun ModalDemo() { data class GalleryItem(val url: String, val description: String) val galleryItems = listOf( GalleryItem( "https://images.unsplash.com/photo-1472214103451-9374bd1c798e" + "?q=80&w=1080&auto=format&fit=crop", "Golden wheat field", ), GalleryItem( "https://images.unsplash.com/photo-1469474968028-56623f02e42e" + "?q=80&w=1080&auto=format&fit=crop", "Mountain landscape", ), GalleryItem( "https://images.unsplash.com/photo-1500534623283-312aade485b7" + "?q=80&w=1080&auto=format&fit=crop", "Sunlit forest", ), GalleryItem( "https://images.unsplash.com/photo-1507525428034-b723cf961d3e" + "?q=80&w=1080&auto=format&fit=crop", "Ocean wave", ), GalleryItem( "https://images.unsplash.com/photo-1501785888041-af3ef285b470" + "?q=80&w=1080&auto=format&fit=crop", "Mountain lake at dawn", ), GalleryItem( "https://images.unsplash.com/photo-1448375240586-882707db888b" + "?q=80&w=1080&auto=format&fit=crop", "Misty pine forest", ), ) val modalState = rememberModalState(initiallyVisible = false) val modalFocusRequester = remember { FocusRequester() } val pagerState = rememberPagerState(pageCount = { galleryItems.size }) val coroutineScope = rememberCoroutineScope() var selectedIndex by remember { mutableIntStateOf(0) } val canGoPrevious = pagerState.currentPage > 0 val canGoNext = pagerState.currentPage < galleryItems.lastIndex val previousButtonAlpha by animateFloatAsState( targetValue = if (canGoPrevious) 1f else 0.33f, animationSpec = tween(durationMillis = 180), ) val nextButtonAlpha by animateFloatAsState( targetValue = if (canGoNext) 1f else 0.33f, animationSpec = tween(durationMillis = 180), ) LaunchedEffect(modalState.transitionState.targetState, selectedIndex) { if (modalState.transitionState.targetState) { pagerState.scrollToPage(selectedIndex) modalFocusRequester.requestFocus() } } Box( modifier = Modifier .fillMaxSize() .padding(24.dp), contentAlignment = Alignment.Center, ) { Column( modifier = Modifier.widthIn(max = 420.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( "Select a photo to preview", color = Theme[colors][contentToken], fontSize = 14.sp, fontWeight = FontWeight.Medium, ) FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { galleryItems.forEachIndexed { index, item -> UnstyledButton( onClick = { selectedIndex = index modalState.transitionState.targetState = true }, modifier = Modifier .size(110.dp, 72.dp) .clip(RectangleShape) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken], RectangleShape), indication = LocalIndication.current, ) { Image( painter = rememberUriPainter(item.url), contentDescription = item.description, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, ) } } } } Modal( state = modalState, onKeyEvent = { event -> if (event.type != KeyEventType.KeyDown) return@Modal false when (event.key) { Key.DirectionLeft -> { if (canGoPrevious) { coroutineScope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) } } true } Key.DirectionRight -> { if (canGoNext) { coroutineScope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } } true } else -> false } }, ) { EscapeHandler { modalState.transitionState.targetState = false } Scrim( enter = fadeIn(tween(durationMillis = 220)), exit = fadeOut(tween(durationMillis = 180)), ) Box( modifier = Modifier .fillMaxSize() .focusRequester(modalFocusRequester) .focusable() .pointerInput(Unit) { detectTapGestures { modalState.transitionState.targetState = false } }, contentAlignment = Alignment.Center, ) { AnimatedVisibility( visibleState = modalState.transitionState, enter = scaleIn(animationSpec = tween(220), initialScale = 0.97f) + fadeIn(tween(220)), exit = scaleOut(animationSpec = tween(180), targetScale = 0.98f) + fadeOut(tween(180)), ) { BoxWithConstraints( modifier = Modifier .fillMaxSize() .padding(vertical = 20.dp), contentAlignment = Alignment.Center, ) { Box( modifier = Modifier .fillMaxWidth() .widthIn(max = 900.dp) .pointerInput(Unit) { detectTapGestures { } }, contentAlignment = Alignment.Center, ) { HorizontalPager( state = pagerState, pageSize = PageSize.Fill, pageSpacing = 18.dp, contentPadding = PaddingValues(horizontal = 34.dp), modifier = Modifier .fillMaxWidth() .padding(vertical = 20.dp), ) { page -> Image( painter = rememberUriPainter(galleryItems[page].url), contentDescription = galleryItems[page].description, modifier = Modifier .fillMaxSize() .clip(RectangleShape) .background(Theme[colors][mutedContentToken]), contentScale = ContentScale.Crop, ) } UnstyledButton( onClick = { coroutineScope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) } }, enabled = canGoPrevious, interactionSource = remember { MutableInteractionSource() }, modifier = Modifier .align(Alignment.CenterStart) .padding(start = 16.dp) .clip(CircleShape) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken], CircleShape) .alpha(previousButtonAlpha), indication = LocalIndication.current, ) { Box(Modifier.padding(12.dp)) { UnstyledIcon( imageVector = Lucide.ArrowLeft, contentDescription = "Previous image", ) } } UnstyledButton( onClick = { coroutineScope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } }, enabled = canGoNext, interactionSource = remember { MutableInteractionSource() }, modifier = Modifier .align(Alignment.CenterEnd) .padding(end = 16.dp) .clip(CircleShape) .background(Theme[colors][surfaceToken]) .border(1.dp, Theme[colors][borderToken], CircleShape) .alpha(nextButtonAlpha), indication = LocalIndication.current, ) { Box(Modifier.padding(12.dp)) { UnstyledIcon( imageVector = Lucide.ArrowRight, contentDescription = "Next image", ) } } } } } } } } } ``` ## Installation ```kotlin implementation("com.composables:composeunstyled-modal:2.10.0") ``` ## Anatomy ```kotlin Modal(state = state) { Scrim() } ``` ## Concepts - `ModalState` controls whether the modal is visible. - `Modal` renders content in a modal layer. - `Scrim` renders a modal overlay that follows the modal transition state. - `modalFragment()` marks content that should keep the modal mounted during transitions. ## Accessibility Use higher-level components such as `Dialog` or `UnstyledModalBottomSheet` when you need built-in dismiss behavior and semantics. ## Code Examples ### Showing modal content Use the `rememberModalState()` function to create the modal state: ```kotlin expandable val state = rememberModalState(initiallyVisible = true) Modal(state = state) { Scrim() Box(Modifier.modalFragment()) { BasicText("Modal content") } } ``` ### Adding a scrim Use `Scrim` inside `Modal` content to render a ready-made overlay: ```kotlin expandable Modal(state = state) { Scrim(scrimColor = Color.Black.copy(alpha = 0.4f)) } ``` ### Closing a modal from Escape Use the `onKeyEvent` parameter to handle keyboard dismissal: ```kotlin expandable Modal( state = state, onKeyEvent = { event -> if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) { state.transitionState.targetState = false true } else { false } }, ) { Box(Modifier.modalFragment()) { BasicText("Modal content") } } ``` ## API Reference ### ModalState | Parameter | Type | Description | |-----------|------|-------------| | `hasMountedFragments` | `Boolean` | | | `isAttachedToWindow` | `Boolean` | | | `suspend fun awaitAttachedToWindow()` | `suspend () -> Unit` | | ### rememberModalState | Parameter | Type | Description | |-----------|------|-------------| | `initiallyVisible` | `Boolean` | | ### Modal | Parameter | Type | Description | |-----------|------|-------------| | `state` | `ModalState` | | | `onKeyEvent` | `(KeyEvent) -> Boolean` | | | `content` | `ModalScope.() -> Unit` | | ### ModalScope.Scrim | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | | | `scrimColor` | `Color` | | | `enter` | `EnterTransition` | | | `exit` | `ExitTransition` | | --- --- title: Portal description: A same-window portal utility for rendering content from one place in composition into a shared host. --- ## Installation ```kotlin implementation("com.composables:composeunstyled-portal:2.10.0") ``` ## Anatomy ```kotlin PortalHost { Portal { BasicText("Portal content") } } ``` ## Concepts - `PortalHost` provides the destination where portal content is rendered. - `Portal` sends content to the nearest `PortalHost`, or renders nothing when no host is available. - Portal content is rendered after the host content, inside the same window. ## Code Examples ### Rendering content in a portal Place `PortalHost` above the content that needs to render portals: ```kotlin expandable PortalHost { BasicText("Screen content") Portal { BasicText("Portal content") } } ``` ### Showing portal content conditionally Add or remove the `Portal` from composition to control whether its content is rendered: ```kotlin expandable var showPortal by remember { mutableStateOf(false) } PortalHost { BasicText( text = "Show portal", modifier = Modifier.clickable { showPortal = true } ) if (showPortal) { Portal { BasicText("Portal content") } } } ``` ### Hosting multiple portals A single `PortalHost` can render content from multiple `Portal` calls: ```kotlin expandable PortalHost { Portal { BasicText("First portal") } Portal { BasicText("Second portal") } } ``` ## API Reference ### PortalHost | Parameter | Type | Description | |-----------|------|-------------| | `modifier` | `Modifier` | | | `key` | `PortalTarget` | | | `content` | `() -> Unit` | | ### Portal | Parameter | Type | Description | |-----------|------|-------------| | `target` | `PortalTarget` | | | `content` | `() -> Unit` | | --- --- title: Window Container Size description: A Composable function that returns the current window size and automatically triggers recomposition when the window is resized, enabling responsive layouts. --- ## Installation ```kotlin implementation("com.composables:composeunstyled-window-container-size:2.10.0") ``` ## Code Examples ### Basic Example Use `currentWindowContainerSize()` to get the current window dimensions: ```kotlin expandable title="WindowContainerSizeDemo.kt" githubUrl="https://github.com/composablehorizons/compose-unstyled/blob/main/demo/src/commonMain/kotlin/com/composeunstyled/demo/windowcontainersize/WindowContainerSizeDemo.kt" import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.composeunstyled.Text import com.composeunstyled.currentWindowContainerSize import com.composeunstyled.demo.colors import com.composeunstyled.demo.contentToken import com.composeunstyled.demo.mutedContentToken import com.composeunstyled.theme.Theme @Preview @Composable fun WindowContainerSizeDemo() { val windowContainerSize = currentWindowContainerSize() Column( modifier = Modifier.fillMaxSize() .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( text = "Window container size", color = Theme[colors][contentToken], fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Medium, ) Text( text = "${windowContainerSize.width} x ${windowContainerSize.height}", color = Theme[colors][contentToken], fontSize = 36.sp, lineHeight = 44.sp, modifier = Modifier.padding(top = 12.dp), ) Text( text = "Resize the window to watch this value update.", color = Theme[colors][mutedContentToken], fontSize = 14.sp, lineHeight = 20.sp, modifier = Modifier.padding(top = 8.dp), ) } } ``` > **HINT:** Resize your __browser's__ width to see the size changing. ```kotlin expandable import com.composeunstyled.currentWindowContainerSize ``` ```kotlin expandable val containerSize = currentWindowContainerSize() ``` --- --- title: Migration to 2.0 description: How to migrate Compose Unstyled projects to 2.0. --- Compose Unstyled 2.0 removes the deprecated 1.x APIs and makes components more strictly unstyled. The last 1.x release is `1.49.9`, published on 2025-04-24. The first 2.0 release is `2.0.0`, published on 2026-05-11. Migrate to `1.49.9` first, apply the available IDE `ReplaceWith()` fixes, then upgrade to `2.0.0`. ## Need help? Ask migration questions in the [Compose Unstyled GitHub repository](https://github.com/composablehorizons/compose-unstyled). ## What changed 2.0 is a breaking release focused on three changes: - The old aggregate `composeunstyled` artifact was removed. - The old `com.composables.core` package was removed. - Components no longer apply styling, layout, or sizing opinions for you. Most migrations are either dependency changes or moving visual parameters into your own layout and modifiers. ## Update dependencies If you want the same broad API surface as 1.x, replace the old aggregate artifact with `composeunstyled-primitives`: ```kotlin implementation("com.composables:composeunstyled-primitives:2.10.0") ``` For smaller dependency graphs, depend only on the modules you use: ```kotlin implementation("com.composables:composeunstyled-button:2.10.0") implementation("com.composables:composeunstyled-dropdown-menu:2.10.0") implementation("com.composables:composeunstyled-text-field:2.10.0") implementation("com.composables:composeunstyled-theming:2.10.0") ``` Components and theming are now separate. Add `composeunstyled-theming` when you use `Text`, `LocalContentColor`, `LocalTextStyle`, `ProvideContentColor`, `ProvideTextStyle`, themes, or minimum interactive size helpers. ## Update imports Remove imports from `com.composables.core`. Bottom Sheet and Modal Bottom Sheet APIs now live in `com.composeunstyled`. ```kotlin expandable // 1.x import com.composables.core.BottomSheet import com.composables.core.ModalBottomSheet // 2.0 import com.composeunstyled.UnstyledBottomSheet import com.composeunstyled.UnstyledModalBottomSheet ``` The theming APIs moved under `com.composeunstyled.theme`: ```kotlin expandable import com.composeunstyled.theme.LocalContentColor import com.composeunstyled.theme.Text ``` ## Move styling to your code 2.0 components expose behavior and slots. Visual parameters such as `shape`, `backgroundColor`, `contentColor`, `borderColor`, layout arrangements, and many padding/layout parameters were removed from component APIs. Move those decisions into modifiers, wrappers, or your design-system components: ```kotlin expandable // 1.x UnstyledButton( onClick = onClick, shape = RoundedCornerShape(8.dp), backgroundColor = Color.Black, contentColor = Color.White, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp), ) { Text("Save") } // 2.0 UnstyledButton( onClick = onClick, modifier = Modifier .clip(RoundedCornerShape(8.dp)) .background(Color.Black), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp), ) { Text("Save", color = Color.White) } ``` This applies across components: arrange slots with `Row`, `Column`, `Box`, or your own component wrappers instead of relying on the component to create internal layout. ## Replace removed deprecated names All deprecated 1.x APIs were removed. Prefer this order: 1. Upgrade to `1.49.9`. 2. Build the project and use IDE quick fixes for deprecated APIs with `ReplaceWith()`. 3. Upgrade to `2.0.0`. 4. Fix the remaining behavioral API changes below. Common replacements: | 1.x | 2.0 | | --- | --- | | `Button` | `UnstyledButton` | | `DropdownMenu` | `UnstyledDropdownMenu` | | `DropdownMenuPanel` | `DropdownMenuPanel` scoped inside `UnstyledDropdownMenu` | | `TabGroup`, `TabList`, `Tab`, `TabPanel` | `UnstyledTabGroup` with scoped `TabList`, `Tab`, and `TabPanel` | | `ScrollArea` | Your own scroll container plus `UnstyledVerticalScrollbar` or `UnstyledHorizontalScrollbar` | | `ScrollAreaState` | `ScrollbarState` from `rememberScrollbarState(...)` | | `ModalBottomSheet` | `UnstyledModalBottomSheet` | ## Component-specific changes ### Bottom Sheet `BottomSheet` was split into a container and panel: ```kotlin expandable val sheetState = rememberBottomSheetState( initialDetent = SheetDetent.Hidden, ) UnstyledBottomSheet(state = sheetState) { Sheet { DragIndication() } } ``` Use `sheetState.targetDetent`, `animateTo(...)`, or `jumpTo(...)` to move the sheet. Modal Bottom Sheet now reuses the same `Sheet` and `DragIndication` model. ### Modal Bottom Sheet Pass dimming UI through the new `overlay` slot. The sheet is IME-aware by default through `ModalBottomSheetProperties.offsetForIme`. ```kotlin expandable val sheetState = rememberModalBottomSheetState( initialDetent = SheetDetent.Hidden, ) UnstyledModalBottomSheet( state = sheetState, overlay = { Scrim() }, ) { Sheet { DragIndication() } } ``` ### Dialog `UnstyledDialog` is controlled with `visible`. Put the rendered dialog content in `DialogPanel` and use `paneTitle` when the dialog needs an accessible pane title. ```kotlin expandable var visible by remember { mutableStateOf(false) } UnstyledDialog( visible = visible, onDismissRequest = { visible = false }, ) { DialogPanel(paneTitle = "Settings") { Text("Settings") } } ``` ### Disclosure `UnstyledDisclosure` is now controlled: ```kotlin expandable var expanded by remember { mutableStateOf(false) } UnstyledDisclosure( expanded = expanded, onExpandedChange = { expanded = it }, ) { DisclosureButton { Text("Details") } DisclosedContent { Text("More information") } } ``` ### Dropdown Menu Menu anchor and panel content are now slots on `UnstyledDropdownMenu`. Use `DropdownMenuPanel` and `UnstyledDropdownMenuItem`. Dropdown Menu also has new anchor placement parameters: `side`, `alignment`, `sideOffset`, and `alignmentOffset`. Use them instead of the old `DropdownPanelAnchor` values: ```kotlin expandable var expanded by remember { mutableStateOf(false) } UnstyledDropdownMenu( expanded = expanded, onExpandedChange = { expanded = it }, side = AnchorSide.Bottom, alignment = AnchorAlignment.End, sideOffset = 8.dp, alignmentOffset = 0.dp, panel = { DropdownMenuPanel { UnstyledDropdownMenuItem(onClick = { expanded = false }) { Text("Item") } } }, anchor = { Text("Open") }, ) ``` ### Tooltip Tooltip placement is now controlled with the same anchor placement model as Dropdown Menu. Use `side`, `alignment`, `sideOffset`, and `alignmentOffset` on `UnstyledTooltip`. `TooltipPanel` is scoped inside `UnstyledTooltip`, and its content receives `TooltipPlacement` so custom visuals can react to the resolved placement: ```kotlin expandable UnstyledTooltip( side = AnchorSide.Top, alignment = AnchorAlignment.Center, sideOffset = 8.dp, alignmentOffset = 0.dp, panel = { TooltipPanel { placement -> Text("Placed on ${placement.side}") } }, anchor = { Text("Help") }, ) ``` ### Text Field `UnstyledTextField` now uses Compose's state-based text field API. Store text in a `TextFieldState` and render the actual editable text through the scoped `TextInput` slot. Text Field does not provide leading or trailing icon slots. Place icons in your own layout around `TextInput`: ```kotlin expandable val state = rememberTextFieldState() UnstyledTextField(state = state) { Row { SearchIcon() TextInput( placeholder = { Text("Email") }, ) } } ``` ### Slider `UnstyledSlider` now exposes `track` and `thumb` slots that receive a `SliderState`: ```kotlin expandable var value by remember { mutableStateOf(0f) } UnstyledSlider( value = value, onValueChange = { value = it }, modifier = Modifier.fillMaxWidth(), track = { state -> Box( Modifier .fillMaxWidth() .height(8.dp) .padding(horizontal = 16.dp) .clip(RoundedCornerShape(100.dp)), ) { Box( Modifier .fillMaxHeight() .fillMaxWidth() .background(Color(0xFFCACACA)), ) Box( Modifier .fillMaxHeight() .fillMaxWidth(state.fraction) .background(Color.Black), ) } }, thumb = { state -> val thumbColor = if (state.isDragging) Color.DarkGray else Color.Black Box( modifier = Modifier .size(18.dp) .clip(CircleShape) .background(thumbColor), ) }, ) ``` ### Checkbox, TriState Checkbox, and Radio Group Indicators are scoped child APIs so they can receive the component interaction source: ```kotlin expandable var checked by remember { mutableStateOf(false) } UnstyledCheckbox( checked = checked, onCheckedChange = { checked = it }, ) { CheckedIndicator() } ``` Radio groups also support generic values and scope `RadioButton` to `RadioGroupScope`. ### Toggle Switch Switch behavior and thumb placement are split. Put the visual thumb in `SwitchThumb`: ```kotlin expandable var checked by remember { mutableStateOf(false) } UnstyledSwitch( checked = checked, onCheckedChange = { checked = it }, ) { SwitchThumb() } ``` ### Scrollbars `ScrollArea` was removed. Build the scrollable layout yourself and connect scrollbars with `rememberScrollbarState(...)`: ```kotlin expandable val scrollState = rememberScrollState() val scrollbarState = rememberScrollbarState(scrollState) Box { Column(Modifier.verticalScroll(scrollState)) { // content } UnstyledVerticalScrollbar(scrollbarState) { Thumb() } } ``` ## Review behavior changes After the project compiles, review the screens that use migrated components: - Add your own size constraints if a component used to fill or align content for you. - Add your own background, clipping, border, text color, and content color propagation. - Check modal, dialog, sheet, tooltip, and menu dismissal paths. - Check keyboard navigation for dropdown menus, tab groups, radio groups, sliders, and sheets. - Check scrollable content with scrollbars and bottom sheets. 2.0 keeps accessibility and behavior in the components, but your app now owns the visual and layout contract around them.