Slack (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Slack’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, Modifiers, motion, and haptics.
Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (the workspace-customizable Aubergine sidebar, rounded-square avatars, inline reaction pill chips, first-class threads, the colored mention pills) while making everything idiomatic Android — a
ModalNavigationDrawerfor the sidebar,NavigationBarinstead of a UITabBar,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for DM/message avatars.
1. Color Tokens
// ui/theme/SlackColors.kt
import androidx.compose.ui.graphics.Color
object SlackColors {
// Sidebar (default Aubergine theme — workspace-customizable)
val Aubergine = Color(0xFF4A154B)
val AubergineDark = Color(0xFF3F0E40)
val AubergineActive = Color(0xFF1164A3) // selected channel row bg
val SidebarText = Color(0xFFCFC3CF) // muted lavender-white
val SidebarActiveText = Color(0xFFFFFFFF)
// Brand 4-color logo palette (logo + some chrome)
val LogoYellow = Color(0xFFECB22E)
val LogoPink = Color(0xFFE01E5A)
val LogoGreen = Color(0xFF2EB67D)
val LogoBlue = Color(0xFF36C5F0)
// Canvas (Light content area)
val Canvas = Color(0xFFFFFFFF)
val Surface = Color(0xFFF8F8F8)
val Pressed = Color(0xFFE6E6E6)
val Divider = Color(0xFFDDDDDD)
// Canvas (Dark content area) — charcoal, not black
val DarkCanvas = Color(0xFF1A1D21)
val DarkSurface1 = Color(0xFF222529)
val DarkSurface2 = Color(0xFF2C2D30)
val DarkDivider = Color(0xFF35373B)
// Text
val TextPrimary = Color(0xFF1D1C1D)
val TextSecondary = Color(0xFF616061)
val TextTertiary = Color(0xFF868686)
val DarkTextPrimary = Color(0xFFD1D2D3)
val DarkTextSecondary = Color(0xFFABABAD)
// Semantic & status
val OnlineGreen = Color(0xFF007A5A) // darker than logo green — dot legibility
val MentionRed = Color(0xFFE01E5A)
val TypingBlue = Color(0xFF1264A3) // typing + Huddles strip
val LinkBlue = Color(0xFF1264A3)
// Mention pill backgrounds (light)
val MentionChannelBg = Color(0xFFF5E3BE)
val MentionHereBg = Color(0xFFFEF9E7)
val MentionUserBg = Color(0xFFF9D5DB)
val ReactionSelfBg = Color(0xFFE3F2FC)
}
Slack’s content area is white (light) / charcoal (dark) — the saturated color lives only in the sidebar. Wire a Material 3 lightColorScheme/darkColorScheme for the content canvas. Aubergine is not primary — it is the sidebar’s color and is passed in as a value (workspace-customizable). Use OnlineGreen for send/primary actions.
// ui/theme/Theme.kt
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.foundation.isSystemInDarkTheme
private val SlackLight = lightColorScheme(
primary = SlackColors.OnlineGreen, // send / primary actions
onPrimary = Color.White,
background = SlackColors.Canvas,
onBackground = SlackColors.TextPrimary,
surface = SlackColors.Canvas,
onSurface = SlackColors.TextPrimary,
surfaceVariant = SlackColors.Surface,
outline = SlackColors.Divider,
error = SlackColors.MentionRed,
)
private val SlackDark = darkColorScheme(
primary = SlackColors.OnlineGreen,
onPrimary = Color.White,
background = SlackColors.DarkCanvas,
onBackground = SlackColors.DarkTextPrimary,
surface = SlackColors.DarkSurface1,
onSurface = SlackColors.DarkTextPrimary,
surfaceVariant = SlackColors.DarkSurface2,
outline = SlackColors.DarkDivider,
error = SlackColors.MentionRed,
)
@Composable
fun SlackTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) SlackDark else SlackLight,
typography = SlackTypography,
content = content,
)
2. Typography
Slack Lato (proprietary; evolved from Lato) is the voice. Drop the TTFs in res/font/ and build a FontFamily. Fall back to Lato (Apache 2.0, ships well as a bundled font) — visually very close; the system font (Roboto) loses Lato’s warmth. Use weights 400 / 600 / 700 only — never 300/500/900.
// ui/theme/SlackType.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val SlackLato = FontFamily(
Font(R.font.slack_lato_regular, FontWeight.Normal), // 400 — body
Font(R.font.slack_lato_semibold, FontWeight.SemiBold), // 600 — reactions / threads
Font(R.font.slack_lato_bold, FontWeight.Bold), // 700 — names / headings
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object SlackText {
val WorkspaceName = TextStyle(SlackLato, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 22.sp, letterSpacing = (-0.2).sp)
val ChannelActive = TextStyle(SlackLato, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 20.sp)
val ChannelDefault = TextStyle(SlackLato, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 20.sp)
val ChannelHeader = TextStyle(SlackLato, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 20.sp)
val ChannelTopic = TextStyle(SlackLato, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val Username = TextStyle(SlackLato, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 20.sp)
val Timestamp = TextStyle(SlackLato, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 14.sp)
val MessageBody = TextStyle(SlackLato, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 23.sp)
val ThreadCount = TextStyle(SlackLato, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 13.sp)
val ReactionCount = TextStyle(SlackLato, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 12.sp)
val MentionText = TextStyle(SlackLato, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 23.sp)
val ButtonPrimary = TextStyle(SlackLato, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 15.sp)
val ButtonSecondary = TextStyle(SlackLato, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 14.sp)
val TabLabel = TextStyle(SlackLato, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.1.sp)
val SectionHeader = TextStyle(SlackLato, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 1.0.sp)
val CodeInline = TextStyle(FontFamily.Monospace, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 18.sp)
}
val SlackTypography = Typography(
headlineSmall = SlackText.ChannelHeader,
titleMedium = SlackText.Username,
bodyMedium = SlackText.MessageBody,
labelMedium = SlackText.Timestamp,
labelSmall = SlackText.TabLabel,
)
3. Signature Components
Aubergine Sidebar (workspace-customizable)
Full-height pane in #4A154B (or workspace-custom) — workspace header + section headers + channel rows. Channel text is muted lavender by default, bold white for unread, bold white on a blue row when active. On Android the swipe-in sidebar is a ModalNavigationDrawer.
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.*
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.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
sealed interface ChannelType {
data object Public : ChannelType
data object Private : ChannelType
data class Dm(val avatarUrl: String) : ChannelType
}
data class ChannelRow(
val type: ChannelType,
val name: String,
val isUnread: Boolean,
val mentionCount: Int,
)
data class SidebarSection(val title: String, val channels: List<ChannelRow>)
@Composable
fun SlackSidebar(
workspaceName: String,
sidebarColor: Color, // pass SlackColors.Aubergine or a workspace-custom hex
sections: List<SidebarSection>,
activeChannel: String?,
onSelect: (ChannelRow) -> Unit,
) {
Column(
Modifier
.fillMaxHeight()
.width(280.dp)
.background(sidebarColor),
) {
// Workspace header
Row(
Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
Modifier.size(32.dp).clip(RoundedCornerShape(4.dp)).background(SlackColors.LogoBlue),
contentAlignment = Alignment.Center,
) { Text(workspaceName.take(1), color = Color.White, style = SlackText.Username) }
Column(Modifier.weight(1f)) {
Text(workspaceName, style = SlackText.WorkspaceName, color = Color.White, maxLines = 1)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Box(Modifier.size(8.dp).clip(CircleShape).background(SlackColors.OnlineGreen))
Text("You", style = SlackText.ChannelTopic, color = SlackColors.SidebarText)
}
}
}
LazyColumn(Modifier.fillMaxSize()) {
sections.forEach { section ->
item {
Text(
section.title.uppercase(),
style = SlackText.SectionHeader,
color = SlackColors.SidebarText.copy(alpha = 0.5f),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
)
}
items(section.channels) { ch ->
SidebarChannelRow(ch, isActive = ch.name == activeChannel, onClick = { onSelect(ch) })
}
}
}
}
}
@Composable
fun SidebarChannelRow(channel: ChannelRow, isActive: Boolean, onClick: () -> Unit) {
val nameStyle = if (isActive || channel.isUnread) SlackText.ChannelActive else SlackText.ChannelDefault
val nameColor = if (isActive || channel.isUnread) Color.White else SlackColors.SidebarText
Row(
Modifier
.fillMaxWidth()
.height(32.dp)
.background(if (isActive) SlackColors.AubergineActive else Color.Transparent)
.clickableNoRipple(onClick)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
when (val t = channel.type) {
ChannelType.Public -> Text("#", color = nameColor, style = SlackText.ChannelDefault, modifier = Modifier.width(20.dp))
ChannelType.Private -> Icon(Icons.Filled.Lock, null, tint = nameColor, modifier = Modifier.size(14.dp).width(20.dp))
is ChannelType.Dm -> AsyncImage(
model = t.avatarUrl, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.size(20.dp).clip(RoundedCornerShape(3.dp)),
)
}
Text(channel.name, style = nameStyle, color = nameColor, modifier = Modifier.weight(1f))
if (channel.mentionCount > 0) {
Box(
Modifier.clip(CircleShape).background(SlackColors.MentionRed).padding(horizontal = 6.dp, vertical = 2.dp),
) { Text("${channel.mentionCount}", style = SlackText.ReactionCount, color = Color.White) }
}
}
}
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.remember
import androidx.compose.ui.composed
fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier = composed {
clickable(remember { MutableInteractionSource() }, indication = null, onClick = onClick)
}
Message Row
16.dp horizontal padding; 12.dp top margin for a new sender (4.dp for collapsed consecutive). Leading is a 36.dp rounded-square avatar (4.dp corner — Slack’s signature, NOT a circle) with an 8.dp online dot. Body → reaction chips → thread indicator.
import androidx.compose.foundation.border
import androidx.compose.foundation.lazy.LazyRow
data class Reaction(val emoji: String, val count: Int, val youReacted: Boolean)
@Composable
fun SlackMessageRow(
username: String,
avatarUrl: String?,
statusEmoji: String?,
status: String?,
isOnline: Boolean,
timestamp: String,
body: String,
reactions: List<Reaction>,
threadReplyCount: Int?,
threadLastReply: String?,
isNewSender: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = if (isNewSender) 8.dp else 2.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (isNewSender) {
Box {
if (avatarUrl != null) {
AsyncImage(
model = avatarUrl, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.size(36.dp).clip(RoundedCornerShape(4.dp)), // rounded square
)
} else {
Box(Modifier.size(36.dp).clip(RoundedCornerShape(4.dp)).background(SlackColors.LogoGreen))
}
if (isOnline) {
Box(
Modifier
.align(Alignment.BottomEnd)
.size(10.dp)
.clip(CircleShape)
.background(SlackColors.Canvas)
.padding(1.dp)
) {
Box(Modifier.fillMaxSize().clip(CircleShape).background(SlackColors.OnlineGreen))
}
}
}
} else {
Spacer(Modifier.width(36.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (isNewSender) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(username, style = SlackText.Username, color = SlackColors.TextPrimary)
statusEmoji?.let { Text(it) }
status?.let { Text(it, style = SlackText.ChannelTopic, color = SlackColors.TextSecondary, maxLines = 1) }
Text(timestamp, style = SlackText.Timestamp, color = SlackColors.TextSecondary)
}
}
Text(body, style = SlackText.MessageBody, color = SlackColors.TextPrimary)
if (reactions.isNotEmpty()) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
items(reactions) { ReactionChip(it) }
item { AddReactionChip() }
}
}
if (threadReplyCount != null && threadReplyCount > 0) {
ThreadIndicator(threadReplyCount, threadLastReply ?: "")
}
}
}
}
Reaction Chip
Pill (full radius), Surface default → ReactionSelfBg with a 1.dp blue border when you’ve reacted.
import androidx.compose.material.icons.filled.AddReaction
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun ReactionChip(r: Reaction) {
val haptics = LocalHapticFeedback.current
Row(
Modifier
.clip(CircleShape)
.background(if (r.youReacted) SlackColors.ReactionSelfBg else SlackColors.Surface)
.then(if (r.youReacted) Modifier.border(1.dp, SlackColors.TypingBlue, CircleShape) else Modifier)
.clickableNoRipple { haptics.performHapticFeedback(HapticFeedbackType.LongPress) }
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(r.emoji, style = SlackText.MessageBody)
Text("${r.count}", style = SlackText.ReactionCount, color = SlackColors.TextPrimary)
}
}
@Composable
fun AddReactionChip() {
Box(
Modifier.clip(CircleShape).background(SlackColors.Surface).padding(horizontal = 8.dp, vertical = 4.dp),
) { Icon(Icons.Filled.AddReaction, "Add reaction", tint = SlackColors.TextSecondary, modifier = Modifier.size(14.dp)) }
}
Thread Indicator (threads are first-class)
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
@Composable
fun ThreadIndicator(replyCount: Int, lastReply: String, onClick: () -> Unit = {}) {
Row(
Modifier.fillMaxWidth().clickableNoRipple(onClick),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
// up to 3 overlapping rounded-square replier avatars
Box {
val palette = listOf(SlackColors.LogoYellow, SlackColors.LogoPink, SlackColors.LogoGreen)
repeat(minOf(3, replyCount)) { i ->
Box(
Modifier
.padding(start = (i * 10).dp)
.size(16.dp)
.clip(RoundedCornerShape(3.dp))
.background(palette[i % 3])
.border(1.dp, SlackColors.Canvas, RoundedCornerShape(3.dp))
)
}
}
Text(
"$replyCount ${if (replyCount == 1) "reply" else "replies"}",
style = SlackText.ThreadCount,
color = SlackColors.LinkBlue,
)
Text("• $lastReply", style = SlackText.Timestamp, color = SlackColors.TextSecondary)
Spacer(Modifier.weight(1f))
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null, tint = SlackColors.TextSecondary, modifier = Modifier.size(12.dp))
}
}
Mention Pill (inline)
@channel green, @here yellow, @user red — colored background, bold text, 4.dp corner (not a pill).
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.AnnotatedString
sealed interface Mention {
data object Channel : Mention
data object Here : Mention
data class User(val name: String) : Mention
}
@Composable
fun MentionPill(mention: Mention) {
val (label, bg, fg) = when (mention) {
Mention.Channel -> Triple("@channel", SlackColors.LogoGreen.copy(alpha = 0.2f), Color(0xFF0F7B6C))
Mention.Here -> Triple("@here", SlackColors.LogoYellow.copy(alpha = 0.2f), Color(0xFF665208))
is Mention.User -> Triple("@${mention.name}", SlackColors.MentionUserBg, Color(0xFF5A2731))
}
Text(
label,
style = SlackText.MentionText,
color = fg,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(bg)
.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
Huddles Banner & Message Composer
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Send
@Composable
fun HuddlesBanner(onJoin: () -> Unit) {
Row(
Modifier.fillMaxWidth().height(56.dp).background(SlackColors.TypingBlue).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
// 4-color pinwheel
Column {
Row { Box(Modifier.size(8.dp).background(SlackColors.LogoYellow)); Box(Modifier.size(8.dp).background(SlackColors.LogoPink)) }
Row { Box(Modifier.size(8.dp).background(SlackColors.LogoGreen)); Box(Modifier.size(8.dp).background(SlackColors.LogoBlue)) }
}
Text("Huddle in progress", style = SlackText.ButtonPrimary, color = Color.White)
Spacer(Modifier.weight(1f))
Box(
Modifier.clip(RoundedCornerShape(4.dp)).background(Color.White).clickableNoRipple(onJoin)
.padding(horizontal = 16.dp, vertical = 8.dp),
) { Text("Join", style = SlackText.ButtonSecondary, color = SlackColors.TypingBlue) }
}
}
@Composable
fun MessageComposer(channelName: String, modifier: Modifier = Modifier) {
var text by remember { mutableStateOf("") }
val haptics = LocalHapticFeedback.current
Row(
modifier = modifier
.fillMaxWidth()
.background(SlackColors.Canvas)
.padding(8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Filled.Add, "Attach", tint = SlackColors.TextSecondary, modifier = Modifier.size(40.dp).padding(8.dp))
BasicTextField(
value = text,
onValueChange = { text = it },
textStyle = SlackText.MessageBody.copy(color = SlackColors.TextPrimary),
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.background(SlackColors.Surface)
.padding(horizontal = 12.dp, vertical = 10.dp),
decorationBox = { inner ->
if (text.isEmpty()) Text("Message #$channelName", style = SlackText.MessageBody, color = SlackColors.TextSecondary)
inner()
},
)
Box(
Modifier
.size(32.dp)
.clip(RoundedCornerShape(4.dp)) // square, 4dp — Slack's send button
.background(if (text.isEmpty()) SlackColors.TextTertiary else SlackColors.OnlineGreen)
.clickableNoRipple {
if (text.isNotEmpty()) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
text = ""
}
},
contentAlignment = Alignment.Center,
) { Icon(Icons.AutoMirrored.Filled.Send, "Send", tint = Color.White, modifier = Modifier.size(16.dp)) }
}
}
4. Workspace-Customizable Sidebar Theme
Slack’s signature: every workspace can pick its own sidebar color (admins can set any hex). iOS hands a WorkspaceTheme per screen; in Compose model it as a CompositionLocal and pick readable channel-text color from luminance so any custom hex stays accessible.
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.luminance
data class WorkspaceTheme(
val sidebarColor: Color = SlackColors.Aubergine,
val activeChannelColor: Color = SlackColors.AubergineActive,
) {
companion object {
val Aubergine = WorkspaceTheme()
val Monument = WorkspaceTheme(Color(0xFF2C2D30), Color(0xFF481349))
val Tangerine = WorkspaceTheme(Color(0xFFCA4400), Color(0xFF6B140C))
fun custom(sidebar: Color, active: Color) = WorkspaceTheme(sidebar, active)
}
}
val LocalWorkspaceTheme = compositionLocalOf { WorkspaceTheme.Aubergine }
@Composable
fun ProvideWorkspaceTheme(theme: WorkspaceTheme, content: @Composable () -> Unit) =
CompositionLocalProvider(LocalWorkspaceTheme provides theme, content = content)
// Channel text must remain legible on any chosen sidebar hex
fun sidebarTextColor(sidebar: Color): Color =
if (sidebar.luminance() > 0.5f) SlackColors.TextPrimary else SlackColors.SidebarText
5. Navigation
Slack’s sidebar is the ModalNavigationDrawer (swipe-right to open, swipe-left to close — matches the spec). The five destinations (Home, DMs, Activity, Later, Search) are a Material 3 NavigationBar. Active state is a bold neutral icon, not a color shift. Solid surface, no blur. Threads are first-class: on phones the thread “pane” is a full-screen destination that slides in from the right.
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.*
@Composable
fun SlackBottomBar(selected: Int, unreadActivity: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = SlackColors.Canvas, // solid, no blur
tonalElevation = 0.dp,
) {
data class Tab(val label: String, val icon: androidx.compose.ui.graphics.vector.ImageVector, val badge: Int = 0)
val tabs = listOf(
Tab("Home", Icons.Filled.Home),
Tab("DMs", Icons.AutoMirrored.Filled.Chat),
Tab("Activity", Icons.Filled.Notifications, badge = unreadActivity),
Tab("Later", Icons.Filled.BookmarkBorder),
Tab("Search", Icons.Filled.Search),
)
tabs.forEachIndexed { i, t ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = {
if (t.badge > 0) {
BadgedBox(badge = { Badge(containerColor = SlackColors.MentionRed) { Text("${t.badge}") } }) {
Icon(t.icon, t.label, modifier = Modifier.size(24.dp))
}
} else {
Icon(t.icon, t.label, modifier = Modifier.size(24.dp))
}
},
label = { Text(t.label, style = SlackText.TabLabel) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = SlackColors.TextPrimary, // bold neutral, no color shift
selectedTextColor = SlackColors.TextPrimary,
unselectedIconColor = SlackColors.TextSecondary,
unselectedTextColor = SlackColors.TextSecondary,
indicatorColor = Color.Transparent,
),
)
}
}
}
// Sidebar wrapper
@Composable
fun SlackScaffold(drawerState: DrawerState, sidebar: @Composable () -> Unit, content: @Composable () -> Unit) {
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = { ModalDrawerSheet(drawerContainerColor = Color.Transparent) { sidebar() } },
content = content,
)
}
6. Motion
| Moment | Compose recipe |
|---|---|
| Sidebar open/close | ModalNavigationDrawer 1:1 drag; commit spring(dampingRatio = 0.8f, stiffness = 600f) ~250ms + HapticFeedbackType.TextHandleMove |
| Message send | send button animateFloatAsState 1 → 0.9 → 1 over 200ms; message enters via AnimatedVisibility scaleIn(0.96f) + fadeIn() 250ms |
| Reaction add | chip Animatable 0 → 1.15 → 1 spring(stiffness = 500f) ~300ms + soft haptic |
| Thread pane open | nav transition slideInHorizontally { it } (from right) 250ms ease-out |
| Huddle join | full-screen fadeIn + HapticFeedbackType.Confirm (success) |
| Typing indicator | rememberInfiniteTransition opacity 1.0 ↔ 0.6 over 1.2s |
// Reaction chip pop-in
@Composable
fun rememberChipPop(trigger: Any): Float {
val scale = remember { Animatable(0f) }
LaunchedEffect(trigger) {
scale.animateTo(1.15f, spring(dampingRatio = 0.6f, stiffness = 500f))
scale.animateTo(1f, spring(dampingRatio = 0.7f))
}
return scale.value
}
Keep reactions gentle — a single spring scale, never elaborate. Haptics: prefer LocalHapticFeedback; soft on send/reaction (LongPress), Confirm on huddle join, TextHandleMove on workspace switch. Honor reduce-motion by skipping the sidebar spring and chip pop while still firing haptics.
7. Icons
Slack ships custom glyphs (4-color pinwheel, channel #); the closest first-party set is androidx.compose.material:material-icons-extended. The public-channel # renders as a text character (universal glyph), not an icon — icons only for private/muted. The pinwheel ships as a vector drawable.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Home (tab) | house / house.fill |
Icons.Filled.Home |
| DMs (tab) | bubble.left.and.bubble.right |
Icons.AutoMirrored.Filled.Chat |
| Activity (tab) | bell / bell.fill |
Icons.Filled.Notifications |
| Later (tab) | bookmark / bookmark.fill |
Icons.Filled.BookmarkBorder / Icons.Filled.Bookmark |
| Search (tab) | magnifyingglass |
Icons.Filled.Search |
| Channel (public) | number |
text # (universal glyph) |
| Channel (private) | lock.fill |
Icons.Filled.Lock |
| Mute | bell.slash |
Icons.Filled.NotificationsOff |
| Huddle start | headphones |
Icons.Filled.Headphones |
| Channel info | info.circle |
Icons.Filled.Info |
| Send | paperplane.fill |
Icons.AutoMirrored.Filled.Send |
| Attach | plus |
Icons.Filled.Add |
| Emoji picker | face.smiling |
Icons.Filled.AddReaction |
| Mention | at |
Icons.Filled.AlternateEmail |
| Voice note | mic |
Icons.Filled.Mic |
| Formatting | textformat |
Icons.Filled.TextFormat |
| Thread chevron | chevron.right |
Icons.AutoMirrored.Filled.KeyboardArrowRight |
8. Minimum SDK & Accessibility Notes
- minSdk 24.
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); system-bar icon color follows light/dark. ApplyScaffoldinsets so the top nav clears the status bar and the tab bar clears gesture nav; the composer must rise above the IME (Modifier.imePadding()). The sidebar (ModalNavigationDrawer) is edge-to-edge. - Font scaling:
sphonors user scale on message body, username, channel name, thread indicator. Pin timestamps, reaction counts, tab labels, and section headers to fixed sizes (layout-sensitive) via a fixed-fontScaleLocalDensity. Code inline scales in the monospace family. - TalkBack: merge a message row into one element —
Modifier.semantics(mergeDescendants = true)with a combined label: “Sarah Chen, online, status on vacation, 10:42 AM. {body}. 3 reactions. 5 replies in thread.” The reaction chips and thread indicator are nested actions. - Touch targets: reaction chips are ~28.dp tall — give them a 44.dp effective hit via padding; send button is 32.dp visual / 44.dp hit; sidebar rows are 32.dp full-row tappable.
- Contrast:
#1D1C1Don#FFFFFFpasses WCAG AAA;#CFC3CFon#4A154Bpasses AA at 15sp. Mention pills have carefully-paired bg/fg. For a workspace-custom sidebar hex, runsidebarTextColor()(luminance split) so channel text never fails contrast. - RTL: the sidebar drawer flips to the right edge automatically with
ModalNavigationDrawerunder an RTLLayoutDirection; the thread pane slide direction flips with it. - Dynamic color: do not enable Material You
dynamicColorScheme()for the content canvas — Slack’s identity is the fixed white/charcoal canvas plus the aubergine sidebar. The sidebar’s color IS the customization point, and it is workspace-data-driven (any admin hex), not wallpaper-driven — that is the intentional theming surface.