coderef

Slack

Slack's iOS app is defined by a single unmistakable color: Slack Aubergine (#4A154B), the deep wine-purple of the default left sidebar.
DownloadRaw
Slack

Build with the Slack look.

Palette, type and components from this DESIGN.md, applied to a sample hero.

Palette

At a glance

Theme
light
Typefaces
Slack Lato, Menlo, Operator Mono
Sections
8
Length
4,857 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Slack

Light and dark previews, tokens and a ready-to-use package, generated from this DESIGN.md. No AI involved: the same file always produces the same output.

Generating design system…
DownloadRaw

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 ModalNavigationDrawer for the sidebar, NavigationBar instead of a UITabBar, sp/dp instead of pt.

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 BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); system-bar icon color follows light/dark. Apply Scaffold insets 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: sp honors 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-fontScale LocalDensity. 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: #1D1C1D on #FFFFFF passes WCAG AAA; #CFC3CF on #4A154B passes AA at 15sp. Mention pills have carefully-paired bg/fg. For a workspace-custom sidebar hex, run sidebarTextColor() (luminance split) so channel text never fails contrast.
  • RTL: the sidebar drawer flips to the right edge automatically with ModalNavigationDrawer under an RTL LayoutDirection; 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.
All mobile designs →

Brand names describe the visual style only; coderef is not affiliated with or endorsed by any brand listed. Licenses