coderef

Telegram

Telegram's iOS app is a messenger that lets the user's taste dominate the chrome. Out of the box it ships a confident Sky Blue (#0088CC) accent over a true-white or pure-black canvas, but the entire color system is a "themeable surface" …
DownloadRaw
Telegram

Build with the Telegram look.

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

Palette

At a glance

Theme
light
Typefaces
SF Pro Text, SF Pro Display, SF Mono
Sections
8
Length
4,370 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Telegram

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

Telegram (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Telegram’s visual language to Android with Jetpack Compose (Material 3): a themeable color system, 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. Telegram’s whole identity is that the accent is a single user-chosen variable and the UI rhythm (bubble shape, type scale, gesture language) holds steady no matter what color is poured in. This file keeps that identity — a themeable accent via CompositionLocal, the 17dp bubble with a 6dp notch tail, the floating voice pill, Lottie animated emoji — while making everything idiomatic Android: NavigationBar instead of a UITabBar, a translucent Surface instead of .regularMaterial blur, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for avatars, and com.airbnb.android:lottie-compose6.4+ for animated emoji & sticker playback. No color extraction — Telegram’s accent comes from the theme, not media, so androidx.palette is not required.

1. Color Tokens

Telegram’s entire color system is themeable — the accent flows through as a single variable. Model it as a TelegramTheme data class hoisted into a CompositionLocal so every consuming composable re-tints live when the user picks a new accent.

// ui/theme/TelegramColors.kt
import androidx.compose.ui.graphics.Color

object TelegramColors {
    // Default Accent (user-themeable)
    val Accent        = Color(0xFF0088CC)
    val AccentLight   = Color(0xFF40A7E3)
    val AccentPressed = Color(0xFF0071B0)

    // Bubble (default blue theme)
    val BubbleOutgoing     = Color(0xFF2B86FD)
    val BubbleOutgoingTop  = Color(0xFF2B86FD) // gradient top
    val BubbleOutgoingBot  = Color(0xFF61B3FF) // gradient bottom
    val BubbleIncomingLight = Color(0xFFFFFFFF)
    val BubbleIncomingDark  = Color(0xFF2A2A2A)
    val SecretOutgoing      = Color(0xFF4EA53C)

    // Canvas
    val CanvasLight   = Color(0xFFFFFFFF)
    val CanvasDark    = Color(0xFF212121)
    val CanvasOLED    = Color(0xFF000000)
    val ChatBGBlue    = Color(0xFFDBE7F4) // default soft-blue wallpaper
    val Surface1Light = Color(0xFFF7F7F7)
    val Surface2Light = Color(0xFFEFEFF4)
    val Surface1Dark  = Color(0xFF1C1C1C)
    val Surface2Dark  = Color(0xFF2C2C2E)
    val DividerLight  = Color(0xFFC7C7CC)
    val DividerDark   = Color(0xFF383838)
    val OLEDSurface   = Color(0xFF0A0A0A)

    // Text
    val TextPrimary       = Color(0xFF000000)
    val TextSecondary     = Color(0xFF707579)
    val TextTertiary      = Color(0xFFA0A6AD)
    val TextPrimaryDark   = Color(0xFFFFFFFF)
    val TextSecondaryDark = Color(0xFF8D8E93)

    // Semantic
    val ReadBlue    = Color(0xFF0088CC)
    val SentGray    = Color(0xFFB1B1B1)
    val ErrorRed    = Color(0xFFE53935)
    val Destructive = Color(0xFFE35561)
    val OnlineGreen = Color(0xFF4DD364)
    val PremiumA    = Color(0xFFAE6FFD)
    val PremiumB    = Color(0xFFCE6BFF)
}

// Sender color palette (group chats) — assign by stable sender id
object TgSenderColors {
    private val palette = listOf(
        Color(0xFFFC5C51), Color(0xFFFA790F), Color(0xFF895DD5), Color(0xFF0FB297),
        Color(0xFF00C1A6), Color(0xFF3CA5EC), Color(0xFFFF5274),
    )
    fun forSender(id: Int): Color = palette[(id % palette.size + palette.size) % palette.size]
}

Themeable accent via CompositionLocal

// ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color

data class TelegramTheme(
    val accent: Color = TelegramColors.Accent,
    val outgoingBubble: Color = TelegramColors.BubbleOutgoing,
    val useGradientBubbles: Boolean = false,
    val isDark: Boolean = false,
    val isOLED: Boolean = false,
) {
    val canvas: Color get() = when {
        isOLED -> TelegramColors.CanvasOLED
        isDark -> TelegramColors.CanvasDark
        else   -> TelegramColors.CanvasLight
    }
    val textPrimary: Color get() = if (isDark) TelegramColors.TextPrimaryDark else TelegramColors.TextPrimary
    val textSecondary: Color get() = if (isDark) TelegramColors.TextSecondaryDark else TelegramColors.TextSecondary
    val incomingBubble: Color get() = if (isDark) TelegramColors.BubbleIncomingDark else TelegramColors.BubbleIncomingLight
}

val LocalTelegramTheme = staticCompositionLocalOf { TelegramTheme() }

@Composable
fun TelegramAppTheme(
    theme: TelegramTheme = TelegramTheme(),
    content: @Composable () -> Unit,
) {
    // Derive a Material 3 scheme from the live accent so ripples/dividers inherit it
    val scheme = if (theme.isDark) {
        darkColorScheme(
            primary = theme.accent,
            background = theme.canvas,
            surface = if (theme.isOLED) TelegramColors.OLEDSurface else TelegramColors.Surface1Dark,
            onSurface = TelegramColors.TextPrimaryDark,
            outline = TelegramColors.DividerDark,
            error = TelegramColors.ErrorRed,
        )
    } else {
        lightColorScheme(
            primary = theme.accent,
            background = theme.canvas,
            surface = TelegramColors.Surface1Light,
            onSurface = TelegramColors.TextPrimary,
            outline = TelegramColors.DividerLight,
            error = TelegramColors.ErrorRed,
        )
    }
    CompositionLocalProvider(LocalTelegramTheme provides theme) {
        MaterialTheme(colorScheme = scheme, typography = TelegramTypography, content = content)
    }
}

Change theme.accent (e.g. to TgSenderColors-style green, rose, or purple) at the app root and every consuming view re-tints live — that is the Telegram product.

2. Typography

Telegram ships no proprietary font on mobile — it uses the platform UI font. On iOS that’s SF Pro; the Android system equivalent is Roboto, so use FontFamily.Default and FontFamily.Monospace for code blocks. Sizes follow Apple HIG / Material parity at 1:1 pt→sp.

// ui/theme/TelegramType.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

private val Sans = FontFamily.Default      // Roboto on Android (Telegram uses the system UI font)
private val Mono = FontFamily.Monospace    // code blocks / pre

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1, weights preserved)
object TelegramText {
    val LargeTitle      = TextStyle(Sans, fontWeight = FontWeight.Bold,    fontSize = 34.sp, lineHeight = 38.sp, letterSpacing = 0.37.sp)
    val ScreenTitle     = TextStyle(Sans, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 20.sp, letterSpacing = (-0.4).sp)
    val ContactName     = TextStyle(Sans, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 21.sp, letterSpacing = (-0.4).sp)
    val BubbleBody      = TextStyle(Sans, fontWeight = FontWeight.Normal,   fontSize = 17.sp, lineHeight = 22.sp, letterSpacing = (-0.4).sp)
    val GroupSender     = TextStyle(Sans, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 17.sp, letterSpacing = (-0.2).sp)
    val MessagePreview  = TextStyle(Sans, fontWeight = FontWeight.Normal,   fontSize = 15.sp, lineHeight = 20.sp, letterSpacing = (-0.2).sp)
    val TimestampList   = TextStyle(Sans, fontWeight = FontWeight.Normal,   fontSize = 13.sp, lineHeight = 16.sp)
    val TimestampBubble = TextStyle(Sans, fontWeight = FontWeight.Normal,   fontSize = 11.sp, lineHeight = 11.sp)
    val SectionHeader   = TextStyle(Sans, fontWeight = FontWeight.Normal,   fontSize = 13.sp, lineHeight = 16.sp)
    val Body            = TextStyle(Sans, fontWeight = FontWeight.Normal,   fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = (-0.4).sp)
    val Callout         = TextStyle(Sans, fontWeight = FontWeight.Normal,   fontSize = 16.sp, lineHeight = 21.sp, letterSpacing = (-0.3).sp)
    val Footnote        = TextStyle(Sans, fontWeight = FontWeight.Normal,   fontSize = 13.sp, lineHeight = 17.sp, letterSpacing = (-0.1).sp)
    val Caption         = TextStyle(Sans, fontWeight = FontWeight.Normal,   fontSize = 12.sp, lineHeight = 16.sp)
    val TabLabel        = TextStyle(Sans, fontWeight = FontWeight.Medium,   fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
    val Button          = TextStyle(Sans, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 17.sp, letterSpacing = (-0.4).sp)
    val Code            = TextStyle(Mono, fontWeight = FontWeight.Normal,   fontSize = 15.sp, lineHeight = 21.sp)
    val Blockquote      = TextStyle(Sans, fontWeight = FontWeight.Normal,   fontStyle = FontStyle.Italic, fontSize = 17.sp, lineHeight = 22.sp, letterSpacing = (-0.4).sp)
}

// Map onto Material 3 slots so stock components inherit the scale
val TelegramTypography = Typography(
    headlineLarge = TelegramText.LargeTitle,
    titleMedium   = TelegramText.ContactName,
    bodyLarge     = TelegramText.BubbleBody,
    bodyMedium    = TelegramText.MessagePreview,
    labelSmall    = TelegramText.TabLabel,
)

3. Signature Components

Compose Bar (with morphing send)

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp

@Composable
fun TgComposeBar(
    onSend: (String) -> Unit,
    onSendSilent: (String) -> Unit,
    onSchedule: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val theme = LocalTelegramTheme.current
    var text by remember { mutableStateOf("") }
    var menuOpen by remember { mutableStateOf(false) }
    val haptics = LocalHapticFeedback.current

    Row(
        modifier = modifier
            .fillMaxWidth()
            .background(if (theme.isDark) TelegramColors.Surface1Dark else TelegramColors.CanvasLight)
            .padding(horizontal = 16.dp, vertical = 10.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        Icon(
            Icons.Filled.AttachFile, "Attach",
            tint = theme.textSecondary,
            modifier = Modifier.size(22.dp).rotate(45f),
        )
        BasicTextField(
            value = text,
            onValueChange = { text = it },
            textStyle = TelegramText.Callout.copy(color = theme.textPrimary),
            modifier = Modifier.weight(1f),
            decorationBox = { inner ->
                if (text.isEmpty()) Text("Message", style = TelegramText.Callout, color = TelegramColors.TextTertiary)
                inner()
            },
        )
        Icon(Icons.Filled.EmojiEmotions, "Emoji", tint = theme.textSecondary, modifier = Modifier.size(22.dp))

        if (text.isEmpty()) {
            Icon(Icons.Filled.Mic, "Voice message", tint = theme.accent, modifier = Modifier.size(22.dp))
        } else {
            Box {
                Icon(
                    Icons.Filled.Send, "Send",
                    tint = theme.accent,
                    modifier = Modifier
                        .size(22.dp)
                        .combinedClickableSend(
                            onClick = {
                                haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // soft impact on send
                                onSend(text); text = ""
                            },
                            onLongClick = { menuOpen = true },
                        ),
                )
                DropdownMenu(menuOpen, onDismissRequest = { menuOpen = false }) {
                    DropdownMenuItem(
                        text = { Text("Send Without Sound") },
                        leadingIcon = { Icon(Icons.Filled.NotificationsOff, null) },
                        onClick = { menuOpen = false; onSendSilent(text); text = "" },
                    )
                    DropdownMenuItem(
                        text = { Text("Schedule Message") },
                        leadingIcon = { Icon(Icons.Filled.Schedule, null) },
                        onClick = { menuOpen = false; onSchedule() },
                    )
                    DropdownMenuItem(
                        text = { Text("Send When Online") },
                        leadingIcon = { Icon(Icons.Filled.Person, null) },
                        onClick = { menuOpen = false; onSend(text); text = "" },
                    )
                }
            }
        }
    }
}

// long-press the send button = the signature silent/scheduled-send gesture
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@Composable
private fun Modifier.combinedClickableSend(onClick: () -> Unit, onLongClick: () -> Unit): Modifier =
    this.combinedClickable(
        interactionSource = remember { MutableInteractionSource() },
        indication = null,
        onClick = onClick,
        onLongClick = onLongClick,
    )

Outgoing Bubble (themeable solid + gradient variant, 6dp notch tail)

import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush

@Composable
fun TgOutgoingBubble(
    text: String,
    timestamp: String,
    isRead: Boolean,
    modifier: Modifier = Modifier,
) {
    val theme = LocalTelegramTheme.current
    // 17dp all corners EXCEPT bottom-trailing = 6dp (subtle notch, not a full point)
    val shape = RoundedCornerShape(topStart = 17.dp, topEnd = 17.dp, bottomStart = 17.dp, bottomEnd = 6.dp)

    Row(modifier.fillMaxWidth().padding(end = 8.dp), horizontalArrangement = Arrangement.End) {
        Spacer(Modifier.weight(0.2f))
        Column(
            modifier = Modifier
                .clip(shape)
                .then(
                    if (theme.useGradientBubbles)
                        Modifier.background(Brush.verticalGradient(listOf(TelegramColors.BubbleOutgoingTop, TelegramColors.BubbleOutgoingBot)))
                    else Modifier.background(theme.outgoingBubble)
                )
                .padding(horizontal = 12.dp, vertical = 8.dp),
            horizontalAlignment = Alignment.End,
        ) {
            Text(text, style = TelegramText.BubbleBody, color = TelegramColors.TextPrimaryDark)
            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
                Text(timestamp, style = TelegramText.TimestampBubble, color = TelegramColors.TextPrimaryDark.copy(alpha = 0.8f))
                Icon(
                    if (isRead) Icons.Filled.DoneAll else Icons.Filled.Done,
                    contentDescription = if (isRead) "Read" else "Sent",
                    tint = TelegramColors.TextPrimaryDark.copy(alpha = 0.9f),
                    modifier = Modifier.size(12.dp),
                )
            }
        }
    }
}

Incoming Bubble (tail notch mirrored to bottom-leading)

import androidx.compose.foundation.border

@Composable
fun TgIncomingBubble(
    text: String,
    senderName: String?,
    senderId: Int?,
    modifier: Modifier = Modifier,
) {
    val theme = LocalTelegramTheme.current
    val shape = RoundedCornerShape(topStart = 17.dp, topEnd = 17.dp, bottomStart = 6.dp, bottomEnd = 17.dp)

    Row(modifier.fillMaxWidth().padding(start = 8.dp)) {
        Column(
            modifier = Modifier
                .clip(shape)
                .background(theme.incomingBubble)
                .then(if (theme.isDark) Modifier else Modifier.border(1.dp, Color(0xFFE5E5E5), shape))
                .padding(horizontal = 12.dp, vertical = 8.dp),
        ) {
            if (senderName != null && senderId != null) {
                Text(senderName, style = TelegramText.GroupSender, color = TgSenderColors.forSender(senderId))
            }
            Text(text, style = TelegramText.BubbleBody, color = theme.textPrimary)
        }
        Spacer(Modifier.weight(0.2f))
    }
}

Floating Voice Mini-Player

import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.shadow

@Composable
fun TgVoiceMiniPlayer(
    title: String,
    progress: Float,
    onClose: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val theme = LocalTelegramTheme.current
    Row(
        modifier = modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp)
            .height(40.dp)
            .shadow(16.dp, RoundedCornerShape(22.dp), spotColor = Color.Black.copy(alpha = 0.16f))
            .clip(RoundedCornerShape(22.dp))
            .background(if (theme.isDark) TelegramColors.Surface1Dark else TelegramColors.CanvasLight)
            .padding(horizontal = 14.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Icon(Icons.Filled.Pause, "Pause", tint = theme.accent, modifier = Modifier.size(14.dp))
        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
            Text(title, style = TelegramText.Footnote.copy(fontWeight = FontWeight.SemiBold), color = theme.textPrimary, maxLines = 1)
            Box(
                Modifier
                    .fillMaxWidth()
                    .height(2.dp)
                    .clip(CircleShape)
                    .background(TelegramColors.DividerLight),
            ) {
                Box(
                    Modifier
                        .fillMaxWidth(progress)
                        .height(2.dp)
                        .clip(CircleShape)
                        .background(theme.accent),
                )
            }
        }
        Icon(
            Icons.Filled.Close, "Close",
            tint = theme.textSecondary,
            modifier = Modifier.size(13.dp).clickableNoRipple(onClose),
        )
    }
}

import androidx.compose.foundation.clickable
@Composable
private fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier =
    this.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)

This pill should be rendered just below the top app bar in the chat Scaffold and survive chat navigation (hoist its state into a shared ViewModel) — persistence across chats is the Telegram differentiator.

Swipe-from-Edge to Reply

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import kotlin.math.roundToInt

@Composable
fun TgSwipeToReply(
    onReply: () -> Unit,
    content: @Composable () -> Unit,
) {
    val theme = LocalTelegramTheme.current
    val haptics = LocalHapticFeedback.current
    val density = LocalDensity.current
    val thresholdPx = with(density) { 60.dp.toPx() }
    var offset by remember { mutableFloatStateOf(0f) }
    var ticked by remember { mutableStateOf(false) }
    val animated by animateFloatAsState(offset, spring(dampingRatio = 0.8f, stiffness = 600f), label = "replyOffset")

    Box(
        Modifier.pointerInput(Unit) {
            detectHorizontalDragGestures(
                onDragEnd = {
                    if (offset >= thresholdPx) onReply()
                    offset = 0f; ticked = false
                },
            ) { _, dragAmount ->
                offset = (offset + dragAmount).coerceIn(0f, thresholdPx + 20f)
                if (offset >= thresholdPx && !ticked) {
                    ticked = true
                    haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // soft tick at threshold
                }
            }
        },
    ) {
        Icon(
            Icons.Filled.Reply, null,
            tint = theme.accent,
            modifier = Modifier
                .align(Alignment.CenterStart)
                .offset { IntOffset((-32 + animated * 0.3f).roundToInt(), 0) }
                .graphicsAlpha((animated / thresholdPx).coerceIn(0f, 1f)),
        )
        Box(Modifier.offset { IntOffset(animated.roundToInt(), 0) }) { content() }
    }
}

import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.IntOffset
private fun Modifier.graphicsAlpha(a: Float) = this.alpha(a)

Animated Emoji / Sticker (Lottie)

Telegram’s crown jewel: a single-emoji message renders large and plays a vector animation once on arrival. iOS uses lottie-ios; Compose uses lottie-compose.

import com.airbnb.lottie.compose.*

@Composable
fun TgAnimatedEmoji(
    assetName: String,         // e.g. "emoji/heart.json" in assets/
    sizeDp: Int = 72,          // single emoji renders at 72dp
    modifier: Modifier = Modifier,
) {
    val composition by rememberLottieComposition(LottieCompositionSpec.Asset(assetName))
    // play once on arrival; bump iterations to loop "interactive" stickers
    val progress by animateLottieCompositionAsState(
        composition,
        iterations = 1,
        speed = 1f,
    )
    LottieAnimation(
        composition = composition,
        progress = { progress },
        modifier = modifier.size(sizeDp.dp),
    )
}

For “interactive” emoji (heart/rocket) that play full-screen across both chats, render a LottieAnimation at ~1024dp in a Box overlay (Popup or top-level ZStack) and auto-dismiss on progress == 1f.

4. Chat List Row

@Composable
fun TgChatListRow(
    avatarUrl: String,
    name: String,
    preview: String,
    timestamp: String,
    unreadCount: Int,
    isPinned: Boolean,
    isMuted: Boolean,
    modifier: Modifier = Modifier,
) {
    val theme = LocalTelegramTheme.current
    Row(
        modifier = modifier
            .fillMaxWidth()
            .height(76.dp)
            .background(if (isPinned) TelegramColors.Surface1Light else theme.canvas)
            .padding(horizontal = 16.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        coil.compose.AsyncImage(
            model = avatarUrl, contentDescription = null,
            modifier = Modifier.size(54.dp).clip(CircleShape),
            contentScale = androidx.compose.ui.layout.ContentScale.Crop,
        )
        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                Text(name, style = TelegramText.ContactName, color = theme.textPrimary, maxLines = 1)
                if (isMuted) Icon(Icons.Filled.NotificationsOff, null, tint = theme.textSecondary, modifier = Modifier.size(11.dp))
            }
            Text(preview, style = TelegramText.MessagePreview, color = theme.textSecondary, maxLines = 1)
        }
        Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
            Text(timestamp, style = TelegramText.TimestampList, color = theme.textSecondary)
            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                if (isPinned) Icon(Icons.Filled.PushPin, null, tint = theme.textSecondary, modifier = Modifier.size(11.dp))
                if (unreadCount > 0) {
                    Box(
                        Modifier
                            .clip(RoundedCornerShape(10.dp))
                            .background(if (isMuted) TelegramColors.TextTertiary else theme.accent)
                            .padding(horizontal = 7.dp, vertical = 2.dp),
                    ) {
                        Text("$unreadCount", style = TelegramText.TimestampList.copy(fontWeight = FontWeight.SemiBold), color = Color.White)
                    }
                }
            }
        }
    }
}

5. Navigation (Tab Bar)

Telegram’s iOS tab bar is a .regularMaterial blur over the canvas with custom outlined glyphs (not SF Symbols everywhere). Android has no first-class live blur — use a translucent NavigationBar (containerColor at ~92% alpha) and ship the brand-specific glyphs (the Telegram plane, the channel megaphone) as vector drawables. The active tint reads from the live themed accent.

@Composable
fun TgBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    val theme = LocalTelegramTheme.current
    NavigationBar(
        containerColor = theme.canvas.copy(alpha = 0.92f), // translucent — no live blur on Android
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            "Contacts" to Icons.Filled.Contacts,
            "Calls"    to Icons.Filled.Call,
            "Chats"    to Icons.Filled.ChatBubble,   // swap for custom Telegram bubble drawable
            "Settings" to Icons.Filled.Settings,
        )
        items.forEachIndexed { i, (label, icon) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = { onSelect(i) },
                icon = { Icon(icon, label, modifier = Modifier.size(28.dp)) },
                label = { Text(label, style = TelegramText.TabLabel) },
                alwaysShowLabel = true,
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = theme.accent,
                    selectedTextColor   = theme.accent,
                    unselectedIconColor = TelegramColors.TextSecondaryDark,
                    unselectedTextColor = TelegramColors.TextSecondaryDark,
                    indicatorColor      = Color.Transparent,
                ),
            )
        }
    }
}

6. Motion

Moment Compose recipe
Bubble arrival enter slideInVertically + slight overshoot spring(dampingRatio = 0.6f)
Animated emoji Lottie iterations = 1, plays once on arrival; interactive emoji full-screen overlay
Swipe-to-reply detectHorizontalDragGestures, soft haptic tick at 60dp threshold
Silent-send long-press combinedClickable(onLongClick) → DropdownMenu (Android long-press ~500ms)
Voice mini-player entry AnimatedVisibility + slideInVertically(initialOffsetY = { -it }) over ~200ms
Reactions palette scaleIn() + fadeIn() from bubble center, spring, ~300ms
Context menu open bubble scale 1.0 → 1.05, translucent scrim, menu fans below
// Bubble arrival — slide up + overshoot
@Composable
fun BubbleAppear(visible: Boolean, content: @Composable () -> Unit) {
    androidx.compose.animation.AnimatedVisibility(
        visible = visible,
        enter = androidx.compose.animation.slideInVertically(
            animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
            initialOffsetY = { it / 3 },
        ) + androidx.compose.animation.fadeIn(),
    ) { content() }
}

Haptics: prefer LocalHapticFeedback. For a true soft impact on send (matching iOS .impact(flexibility: .soft)), use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(8, 80) — light and brief, like Telegram’s send tick.

7. Icons

Telegram uses a mix of system glyphs and a proprietary outlined set. Use Material Icons (material-icons-extended) where semantically equivalent; ship brand-specific glyphs (the Telegram plane, channel megaphone, sticker face, premium gradient star) as vector drawables loaded via ImageVector.vectorResource(R.drawable.…).

Component SF Symbol (iOS) Material Icon (Compose)
Send paperplane.fill Icons.Filled.Send (or custom Telegram plane drawable)
Mic mic.fill Icons.Filled.Mic
Attach paperclip (rotated 45°) Icons.Filled.AttachFile (.rotate(45f))
Emoji face.smiling Icons.Filled.EmojiEmotions
Sticker custom outlined sticker vector drawable
Read ticks checkmark / checkmark.circle.fill Icons.Filled.Done / Icons.Filled.DoneAll
Phone phone.fill Icons.Filled.Call
Video video.fill Icons.Filled.Videocam
Lock (secret chat) lock.fill Icons.Filled.Lock
Pinned pin.fill Icons.Filled.PushPin
Muted speaker.slash.fill Icons.Filled.NotificationsOff
Chats tab bubble.left.and.bubble.right.fill Icons.Filled.ChatBubble (or custom)
Contacts tab person.2.fill Icons.Filled.Contacts
Calls tab phone.fill Icons.Filled.Call
Settings tab gearshape.fill Icons.Filled.Settings
Channel broadcast custom megaphone vector drawable
Premium star custom gradient star vector drawable
Reply arrowshape.turn.up.left.fill Icons.Filled.Reply
Schedule calendar Icons.Filled.Schedule
Silent speaker.slash Icons.Filled.NotificationsOff

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; Lottie + RoundedCornerShape per-corner + motion are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge() in the Activity. System-bar icon contrast must follow the live theme (dark icons on the white canvas, light icons on #212121/OLED). Apply Scaffold insets so the compose bar hugs the keyboard/gesture inset; render the voice mini-player just below the top app bar inside the insets.
  • Themeable accent: the entire system reads LocalTelegramTheme.current.accent. Provide a new TelegramTheme at the app root and every consuming composable recomposes and re-tints live — this is the core product behavior, so never hardcode a blue.
  • OLED mode: expose isOLED as a stored user preference distinct from system dark; when true the canvas becomes pure #000000 and surfaces lift to #0A0A0A.
  • Font scaling: sp honors the user’s font scale — scale bubble body, contact names, previews. Pin the 11sp in-bubble timestamp and 10sp tab labels (clamp via CompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f))).
  • TalkBack: merge bubble text + timestamp + read state into one node — Modifier.semantics(mergeDescendants = true) { contentDescription = "$text, $timestamp, ${if (isRead) "read" else "sent"}" }. The swipe-to-reply gesture and decorative tail notch should be Modifier.clearAndSetSemantics {}.
  • RTL: RoundedCornerShape(topStart=…, bottomStart=…) auto-mirrors under LayoutDirection.Rtl, so the bubble tail notch flips correctly for Arabic/Persian/Hebrew with no extra code.
  • Reduce motion: detect Settings.Global.ANIMATOR_DURATION_SCALE == 0f (or a stored pref) — render Lottie emoji as a static first frame (progress = { 0f }) and skip the bubble-arrival overshoot.
  • Inline Markdown: parse **bold**, __italic__, ||spoiler||, `code` into an AnnotatedString with SpanStyles; render spoiler as a blurred/drawWithContent-masked span that clears on tap.
  • Dynamic color: Telegram already is a user-theming system. Do not layer Material You dynamicColorScheme() on top — the accent must come from the user’s explicit Telegram theme choice, not the wallpaper, or two competing theming systems fight.
All mobile designs →

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