coderef

ChatGPT

ChatGPT's iOS app is a master class in deliberate minimalism. The canvas is pure white (#FFFFFF) in light mode or near-black (#212121) in dark — there is almost no chrome, no color, no decoration.
DownloadRaw
ChatGPT

Build with the ChatGPT look.

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

Palette

At a glance

Theme
light
Typefaces
Söhne, Menlo, SF Mono
Sections
8
Length
4,826 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · ChatGPT

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

ChatGPT (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports ChatGPT’s visual language to Android with Jetpack Compose (Material 3): light + dark color token objects, a Typography set, paste-ready @Composables, the black/white send button, the markdown atoms, and the full-screen voice-mode sphere.

Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (ChatGPT’s monochromatic minimalism, the circular send button, bubble-less assistant text, the pulsing blue voice sphere) while making everything idiomatic Android — a custom asymmetric Shape for the user bubble, ModalNavigationDrawer for the conversation sidebar, an Animatable-driven Canvas sphere, sp/dp instead of pt. ChatGPT is radically flat — shadows appear only on ephemeral floating UI.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for attachment thumbnails, and a markdown renderer such as compose-markdown or Markwon for assistant content (headings, code, tables, LaTeX). No color extraction, so no androidx.palette.

1. Color Tokens

ChatGPT ships first-class light and dark. Define both palettes, then select per system theme.

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

object GptColors {
    // Canvas — pure white light / charcoal (NOT true black) dark
    val Canvas      = Color(0xFFFFFFFF)
    val DarkCanvas  = Color(0xFF212121)

    // Sidebar (darker than canvas — signature treatment)
    val SidebarLight     = Color(0xFFF9F9F9)
    val SidebarDark      = Color(0xFF181818)
    val SidebarActive    = Color(0xFFECECEC)
    val SidebarActiveDk  = Color(0xFF2F2F2F)
    val SidebarHover     = Color(0xFFF0F0F0)
    val SidebarHoverDk   = Color(0xFF252525)
    val Divider          = Color(0xFFE5E5E5)
    val DividerDark      = Color(0xFF424242)

    // Text
    val TextPrimary       = Color(0xFF0D0D0D) // softer than pure #000
    val TextSecondary     = Color(0xFF676767) // placeholder, timestamps
    val TextTertiary      = Color(0xFF8E8E8E) // disabled
    val DarkTextPrimary   = Color(0xFFECECEC)
    val DarkTextSecondary = Color(0xFFB4B4B4)
    val DarkTextTertiary  = Color(0xFF707070)

    // User message bubble (assistant text has NO bubble)
    val UserBubbleLight = Color(0xFFF7F7F8)
    val UserBubbleDark  = Color(0xFF2F2F2F)

    // Code
    val CodeBlockLight  = Color(0xFFF7F7F8)
    val CodeBlockDark   = Color(0xFF1E1E1E)
    val CodeInlineLight = Color(0xFFF0F0F0)
    val CodeInlineDark  = Color(0xFF424242)
    val CodeBorderLight = Color(0xFFE5E5E5)
    val CodeBorderDark  = Color(0xFF363636)

    // Send button (inverse of canvas)
    val SendLight       = Color(0xFF0D0D0D)
    val SendDark        = Color(0xFFFFFFFF)
    val SendDisabled    = Color(0xFFCCCCCC)
    val SendDisabledDk  = Color(0xFF4D4D4D)

    // Semantic — monochromatic; blue only on links
    val LinkBlue    = Color(0xFF2A7FFF)
    val LegacyGreen = Color(0xFF10A37F) // largely retired — wordmark / legacy UI only
    val ErrorRed    = Color(0xFFE53E3E)
    val WarningOrange = Color(0xFFD97706)

    // Voice-mode sphere gradient
    val VoiceBlue1 = Color(0xFF3B82F6)
    val VoiceBlue2 = Color(0xFF60A5FA)
    val VoiceBlue3 = Color(0xFF93C5FD)
}

Wire both into Material 3 schemes and switch on the system setting. ChatGPT supports both modes equally, so unlike most strong-brand apps this one does provide a real lightColorScheme and darkColorScheme.

// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme

private val GptLight = lightColorScheme(
    primary        = GptColors.SendLight,    // monochrome — the "accent" is ink
    onPrimary      = GptColors.Canvas,
    background     = GptColors.Canvas,
    onBackground   = GptColors.TextPrimary,
    surface        = GptColors.Canvas,
    onSurface      = GptColors.TextPrimary,
    surfaceVariant = GptColors.UserBubbleLight,
    outline        = GptColors.Divider,
    error          = GptColors.ErrorRed,
)

private val GptDark = darkColorScheme(
    primary        = GptColors.SendDark,
    onPrimary      = GptColors.TextPrimary,
    background     = GptColors.DarkCanvas,
    onBackground   = GptColors.DarkTextPrimary,
    surface        = GptColors.DarkCanvas,
    onSurface      = GptColors.DarkTextPrimary,
    surfaceVariant = GptColors.UserBubbleDark,
    outline        = GptColors.DividerDark,
    error          = GptColors.ErrorRed,
)

@Composable
fun GptTheme(content: @Composable () -> Unit) =
    MaterialTheme(
        colorScheme = if (isSystemInDarkTheme()) GptDark else GptLight,
        typography = GptTypography,
        content = content,
    )

2. Typography

ChatGPT uses Söhne (Klim Type Foundry, commercially licensed), falling back to Inter, then SF Pro. Drop Söhne/Inter TTFs in res/font/ (lowercase, snake_case); Roboto is the Android system fallback. Weights are concentrated at 400/500/600 — no bold, no thin. Code is always monospace (Menlo / SF Mono on iOS → bundle JetBrains Mono / use the platform monospace family).

// ui/theme/GptType.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 Sohne = FontFamily(
    Font(R.font.sohne_buch,     FontWeight.Normal),   // 400 "Buch"
    Font(R.font.sohne_kraftig,  FontWeight.Medium),   // 500 "Kräftig"
    Font(R.font.sohne_halbfett, FontWeight.SemiBold), // 600 "Halbfett"
)
val GptMono = FontFamily(Font(R.font.jetbrains_mono_regular, FontWeight.Normal)) // code only

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1, same weights/tracking)
object GptText {
    val Body          = TextStyle(Sohne,   fontWeight = FontWeight.Normal,   fontSize = 16.sp, lineHeight = 24.sp)
    val BodyCompact   = TextStyle(Sohne,   fontWeight = FontWeight.Normal,   fontSize = 15.sp, lineHeight = 22.sp)
    val H1            = TextStyle(Sohne,   fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 30.sp, letterSpacing = (-0.2).sp)
    val H2            = TextStyle(Sohne,   fontWeight = FontWeight.SemiBold, fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = (-0.15).sp)
    val H3            = TextStyle(Sohne,   fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
    val H4            = TextStyle(Sohne,   fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 22.sp)
    val ModelChip     = TextStyle(Sohne,   fontWeight = FontWeight.Medium,   fontSize = 14.sp, lineHeight = 14.sp)
    val SidebarTitle  = TextStyle(Sohne,   fontWeight = FontWeight.Medium,   fontSize = 15.sp, lineHeight = 20.sp)
    val SidebarSection = TextStyle(Sohne,  fontWeight = FontWeight.Medium,   fontSize = 12.sp, lineHeight = 12.sp, letterSpacing = 0.2.sp)
    val Button        = TextStyle(Sohne,   fontWeight = FontWeight.Medium,   fontSize = 14.sp, lineHeight = 14.sp)
    val Meta          = TextStyle(Sohne,   fontWeight = FontWeight.Normal,   fontSize = 13.sp, lineHeight = 16.sp)
    val Placeholder   = TextStyle(Sohne,   fontWeight = FontWeight.Normal,   fontSize = 16.sp, lineHeight = 22.sp)
    val LinkText      = TextStyle(Sohne,   fontWeight = FontWeight.Normal,   fontSize = 16.sp, lineHeight = 24.sp)
    val CodeInline    = TextStyle(GptMono, fontWeight = FontWeight.Normal,   fontSize = 14.sp, lineHeight = 18.sp)
    val CodeBlock     = TextStyle(GptMono, fontWeight = FontWeight.Normal,   fontSize = 13.sp, lineHeight = 20.sp)
}

// Map onto Material 3 slots so stock components inherit the brand
val GptTypography = Typography(
    headlineLarge = GptText.H1,
    headlineSmall = GptText.H2,
    titleMedium   = GptText.H3,
    bodyMedium    = GptText.Body,
    labelSmall    = GptText.SidebarSection,
)

Typography follows document rhythm, not UI rhythm — 1.5 line-height on body (24.sp / 16.sp), markdown headings stepping down. Code styles must never be substituted away from monospace.

3. Signature Components

Send Button (the black/white circle)

The single most recognizable ChatGPT component — a 32dp circle, ink-on-canvas inverse, single up-arrow. Becomes a square “stop” during streaming.

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp

@Composable
fun SendButton(
    enabled: Boolean,
    generating: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val dark = isSystemInDarkTheme()
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(
        targetValue = if (pressed) 0.94f else 1f,
        animationSpec = spring(dampingRatio = 0.7f, stiffness = 700f),
        label = "sendScale",
    )
    val circle = when {
        !enabled && !generating -> if (dark) GptColors.SendDisabledDk else GptColors.SendDisabled
        dark -> GptColors.SendDark
        else -> GptColors.SendLight
    }
    val iconTint = if (dark) GptColors.SendLight else GptColors.SendDark
    val haptics = LocalHapticFeedback.current

    Box(
        modifier = modifier
            .size(32.dp)
            .scale(scale)
            .clip(CircleShape)
            .background(circle)
            .clickable(interaction, indication = null, enabled = enabled || generating) {
                haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ≈ .impact solid
                onClick()
            },
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            imageVector = if (generating) Icons.Filled.Stop else Icons.Filled.ArrowUpward,
            contentDescription = if (generating) "Stop generating" else "Send",
            tint = iconTint,
            modifier = Modifier.size(16.dp),
        )
    }
}

User Message Bubble (asymmetric corners)

Right-aligned, max-width ~80%, soft-gray fill, with the “pointer” corner flattened to 4dp on the bottom-trailing edge.

import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text

private val UserBubbleShape = RoundedCornerShape(
    topStart = 18.dp, topEnd = 18.dp, bottomStart = 18.dp, bottomEnd = 4.dp,
)

@Composable
fun UserMessageBubble(text: String, modifier: Modifier = Modifier) {
    val dark = isSystemInDarkTheme()
    Row(modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
        Spacer(Modifier.weight(0.2f)) // ~80% max width
        Box(
            Modifier
                .weight(0.8f, fill = false)
                .clip(UserBubbleShape)
                .background(if (dark) GptColors.UserBubbleDark else GptColors.UserBubbleLight)
                .padding(horizontal = 14.dp, vertical = 10.dp),
        ) {
            Text(
                text,
                style = GptText.Body,
                color = if (dark) GptColors.DarkTextPrimary else GptColors.TextPrimary,
            )
        }
    }
}

Assistant Message (no bubble + feedback row)

Plain markdown-rendered content flowing inline — no bubble, no box — with the canonical 4-icon feedback row beneath.

import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.ThumbDown
import androidx.compose.material.icons.filled.ThumbUp
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.ui.graphics.vector.ImageVector

@Composable
fun AssistantMessage(
    markdown: String,
    onRegenerate: () -> Unit,
    onCopy: () -> Unit,
    onThumbUp: () -> Unit,
    onThumbDown: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val dark = isSystemInDarkTheme()
    val textColor = if (dark) GptColors.DarkTextPrimary else GptColors.TextPrimary
    Column(
        modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            Icon(Icons.Filled.AutoAwesome, null, tint = textColor, modifier = Modifier.size(24.dp))
            // Replace with a real markdown renderer (Markwon / compose-markdown):
            // renders H1/H2/H3, lists, inline code, code blocks, tables, LaTeX.
            Text(markdown, style = GptText.Body, color = textColor)
        }
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 32.dp)) {
            FeedbackIcon(Icons.Filled.Refresh, "Regenerate", onRegenerate)
            FeedbackIcon(Icons.Filled.ContentCopy, "Copy", onCopy)
            FeedbackIcon(Icons.Filled.ThumbUp, "Good response", onThumbUp)
            FeedbackIcon(Icons.Filled.ThumbDown, "Bad response", onThumbDown)
        }
    }
}

@Composable
private fun FeedbackIcon(icon: ImageVector, label: String, onClick: () -> Unit) {
    val dark = isSystemInDarkTheme()
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 1.15f else 1f, label = "thumbScale")
    val haptics = LocalHapticFeedback.current
    Box(
        Modifier
            .size(32.dp) // tight per iOS spec — see §8 (bump to 48dp for strict a11y)
            .scale(scale)
            .clip(CircleShape)
            .clickable(interaction, indication = null) {
                haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ≈ .impact soft
                onClick()
            },
        contentAlignment = Alignment.Center,
    ) {
        Icon(icon, contentDescription = label,
            tint = if (dark) GptColors.DarkTextSecondary else GptColors.TextSecondary,
            modifier = Modifier.size(16.dp))
    }
}

Code Block (with Copy button)

import androidx.compose.foundation.border
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.filled.Check
import kotlinx.coroutines.delay

@Composable
fun CodeBlock(language: String, code: String, modifier: Modifier = Modifier) {
    val dark = isSystemInDarkTheme()
    val bg = if (dark) GptColors.CodeBlockDark else GptColors.CodeBlockLight
    val border = if (dark) GptColors.CodeBorderDark else GptColors.CodeBorderLight
    val secondary = if (dark) GptColors.DarkTextSecondary else GptColors.TextSecondary
    var copied by remember { mutableStateOf(false) }
    val clipboard = androidx.compose.ui.platform.LocalClipboardManager.current

    LaunchedEffect(copied) { if (copied) { delay(1000); copied = false } }

    Column(
        modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp)
            .clip(RoundedCornerShape(8.dp))
            .background(bg)
            .border(1.dp, border, RoundedCornerShape(8.dp)),
    ) {
        Row(
            Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Text(language, style = GptText.SidebarSection, color = secondary)
            Spacer(Modifier.weight(1f))
            Row(
                Modifier.clip(RoundedCornerShape(6.dp)).clickable {
                    clipboard.setText(androidx.compose.ui.text.AnnotatedString(code)); copied = true
                }.padding(4.dp),
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.spacedBy(4.dp),
            ) {
                Icon(if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, null,
                    tint = secondary, modifier = Modifier.size(12.dp))
                Text(if (copied) "Copied!" else "Copy", style = GptText.Button, color = secondary)
            }
        }
        Box(Modifier.fillMaxWidth().height(1.dp).background(border))
        Box(Modifier.horizontalScroll(rememberScrollState()).padding(12.dp)) {
            Text(code, style = GptText.CodeBlock,
                color = if (dark) GptColors.DarkTextPrimary else GptColors.TextPrimary)
        }
    }
}

Model Selector Chip

import androidx.compose.material.icons.filled.KeyboardArrowDown

@Composable
fun ModelSelectorChip(modelName: String, onTap: () -> Unit, modifier: Modifier = Modifier) {
    val dark = isSystemInDarkTheme()
    val text = if (dark) GptColors.DarkTextPrimary else GptColors.TextPrimary
    val border = if (dark) GptColors.DividerDark else GptColors.Divider
    Row(
        modifier
            .clip(CircleShape)
            .border(1.dp, border, CircleShape)
            .clickable { onTap() }
            .padding(horizontal = 10.dp, vertical = 6.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(6.dp),
    ) {
        Icon(Icons.Filled.AutoAwesome, null, tint = text, modifier = Modifier.size(14.dp))
        Text(modelName, style = GptText.ModelChip, color = text)
        Icon(Icons.Filled.KeyboardArrowDown, "Choose model",
            tint = if (dark) GptColors.DarkTextSecondary else GptColors.TextSecondary,
            modifier = Modifier.size(10.dp))
    }
}

Message Composer

Bottom-docked rounded container. When empty: paperclip + globe + mic. When text entered: the send button replaces the trailing icons.

import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Public
import androidx.compose.ui.text.input.TextFieldValue

@Composable
fun Composer(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit,
    onSend: () -> Unit,
    onVoice: () -> Unit,
    onAttach: () -> Unit,
    onWebSearch: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val dark = isSystemInDarkTheme()
    val empty = value.text.isBlank()
    val border = if (dark) GptColors.DividerDark else GptColors.Divider
    val secondary = if (dark) GptColors.DarkTextSecondary else GptColors.TextSecondary

    Row(
        modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 8.dp)
            .clip(RoundedCornerShape(24.dp))
            .background(if (dark) GptColors.DarkCanvas else GptColors.Canvas)
            .border(1.dp, border, RoundedCornerShape(24.dp))
            .padding(horizontal = 8.dp, vertical = 8.dp),
        verticalAlignment = Alignment.Bottom,
        horizontalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        IconHit(Icons.Filled.Add, "Attach", secondary, onAttach)
        Box(Modifier.weight(1f).padding(vertical = 10.dp)) {
            if (value.text.isEmpty()) {
                Text("Message ChatGPT…", style = GptText.Placeholder, color = secondary)
            }
            BasicTextField(
                value = value, onValueChange = onValueChange,
                textStyle = GptText.Body.copy(
                    color = if (dark) GptColors.DarkTextPrimary else GptColors.TextPrimary),
                maxLines = 6,
            )
        }
        if (empty) {
            IconHit(Icons.Filled.Public, "Web search", secondary, onWebSearch)
            Box(
                Modifier.size(32.dp).clip(CircleShape).border(1.dp, border, CircleShape)
                    .clickable { onVoice() },
                contentAlignment = Alignment.Center,
            ) { Icon(Icons.Filled.Mic, "Voice", tint = secondary, modifier = Modifier.size(18.dp)) }
        } else {
            SendButton(enabled = true, generating = false, onClick = onSend)
        }
    }
}

@Composable
private fun IconHit(icon: ImageVector, label: String, tint: Color, onClick: () -> Unit) {
    Box(
        Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)).clickable { onClick() },
        contentAlignment = Alignment.Center,
    ) { Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(20.dp)) }
}

Typing Indicator (3-dot pulse)

import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.ui.draw.alpha

@Composable
fun TypingIndicator(modifier: Modifier = Modifier) {
    val dark = isSystemInDarkTheme()
    val color = if (dark) GptColors.DarkTextSecondary else GptColors.TextSecondary
    val t = rememberInfiniteTransition(label = "typing")
    Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
        repeat(3) { i ->
            val a by t.animateFloat(
                initialValue = 0.3f, targetValue = 1f,
                animationSpec = infiniteRepeatable(
                    tween(600, delayMillis = i * 200), RepeatMode.Reverse),
                label = "dot$i",
            )
            Box(Modifier.size(8.dp).alpha(a).clip(CircleShape).background(color))
        }
    }
}

4. Voice Mode (the distinctive system)

ChatGPT has no color extraction; its flagship dynamic moment is voice mode — a full-screen pulsing blue gradient sphere that morphs as you speak. Port the SwiftUI radial-gradient sphere with an Animatable scale pulse plus a subtle organic deformation. This is the product’s most-recognized animation.

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.foundation.Canvas
import androidx.compose.material.icons.filled.Close
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import kotlin.math.sin

@Composable
fun VoiceModeOverlay(onEnd: () -> Unit) {
    val haptics = LocalHapticFeedback.current
    val pulse = remember { Animatable(1f) }
    val morph = rememberInfiniteTransition(label = "morph")
    val wobble by morph.animateFloat(
        initialValue = 0f, targetValue = (2 * Math.PI).toFloat(),
        animationSpec = infiniteRepeatable(tween(3000, easing = LinearEasing)),
        label = "wobble",
    )

    LaunchedEffect(Unit) {
        haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ≈ .impact solid on entry
        // Continuous organic pulse: scale 0.95 ↔ 1.05 over 2s ease-in-out
        while (true) {
            pulse.animateTo(1.05f, tween(2000))
            pulse.animateTo(0.95f, tween(2000))
        }
    }

    Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
        Canvas(Modifier.size(280.dp).scale(pulse.value)) {
            // Perlin-ish deformation: radius modulated by a couple of sine terms
            val baseR = size.minDimension / 2f
            val r = baseR * (1f + 0.03f * sin(wobble) + 0.02f * sin(wobble * 2.3f))
            drawCircle(
                brush = Brush.radialGradient(
                    colors = listOf(GptColors.VoiceBlue1, GptColors.VoiceBlue2, GptColors.VoiceBlue3),
                    center = center,
                    radius = r,
                ),
                radius = r,
                center = center,
            )
            // Glow: a soft larger translucent ring (Android has no blur shadow on Canvas)
            drawCircle(
                color = GptColors.VoiceBlue1.copy(alpha = 0.25f),
                radius = r * 1.25f,
                center = center,
            )
        }
        Column(
            Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(bottom = 40.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            Text("ChatGPT is listening…", style = GptText.Meta, color = Color.White.copy(alpha = 0.7f))
            Row(
                Modifier.fillMaxWidth().padding(horizontal = 24.dp),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Text("Mute", style = GptText.Button, color = Color.White,
                    modifier = Modifier.clickable { /* toggle mute */ })
                Box(
                    Modifier.size(44.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.2f))
                        .clickable { onEnd() },
                    contentAlignment = Alignment.Center,
                ) { Icon(Icons.Filled.Close, "End voice mode", tint = Color.White,
                    modifier = Modifier.size(18.dp)) }
            }
        }
    }
}

Present this as a full-screen destination with an enter = scaleIn(initialScale = 0.9f) + fadeIn(tween(400)) transition. Drive an AVAudioSession-equivalent via AudioManager/MediaRecorder; request the RECORD_AUDIO runtime permission before entering. It is portrait-locked and ignores window insets (full-bleed sphere).

5. Navigation

ChatGPT has no bottom tab bar. The conversation history is a left drawer — use Material 3 ModalNavigationDrawer (edge-swipe with spring(dampingRatio = 0.8f) on release). The chat “top nav” is a transparent 52dp row: sidebar toggle (leading) + model chip (center-leading) + “New chat” (trailing). The sidebar groups chats by time (Today / Yesterday / Previous 7 Days / Previous 30 Days / Month Year) and is darker than the canvas — the signature treatment.

import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GptScaffold(
    drawerState: DrawerState,
    sections: List<Pair<String, List<String>>>, // section title to chat titles
    onNewChat: () -> Unit,
    chat: @Composable () -> Unit,
) {
    val dark = isSystemInDarkTheme()
    ModalNavigationDrawer(
        drawerState = drawerState,
        drawerContent = {
            ModalDrawerSheet(
                drawerContainerColor = if (dark) GptColors.SidebarDark else GptColors.SidebarLight,
                modifier = Modifier.width(300.dp),
            ) {
                Column {
                    SidebarButton(Icons.Filled.Edit, "New chat", onNewChat)
                    SidebarButton(Icons.Filled.Search, "Search chats") {}
                    LazyColumn {
                        sections.forEach { (title, chats) ->
                            item {
                                Text(title, style = GptText.SidebarSection,
                                    color = if (dark) GptColors.DarkTextSecondary else GptColors.TextSecondary,
                                    modifier = Modifier.padding(start = 12.dp, top = 16.dp, bottom = 4.dp))
                            }
                            items(chats) { c ->
                                Box(
                                    Modifier.fillMaxWidth().height(40.dp)
                                        .padding(horizontal = 12.dp),
                                    contentAlignment = Alignment.CenterStart,
                                ) {
                                    Text(c, style = GptText.SidebarTitle, maxLines = 1,
                                        color = if (dark) GptColors.DarkTextPrimary else GptColors.TextPrimary)
                                }
                            }
                        }
                    }
                }
            }
        },
    ) {
        Scaffold(
            containerColor = if (dark) GptColors.DarkCanvas else GptColors.Canvas,
            topBar = {
                Row(
                    Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    Icon(Icons.Filled.Menu, "Open conversations",
                        tint = if (dark) GptColors.DarkTextPrimary else GptColors.TextPrimary,
                        modifier = Modifier.size(24.dp))
                    Spacer(Modifier.width(8.dp))
                    ModelSelectorChip("GPT-4o", onTap = {})
                    Spacer(Modifier.weight(1f))
                    Icon(Icons.Filled.Edit, "New chat",
                        tint = if (dark) GptColors.DarkTextPrimary else GptColors.TextPrimary,
                        modifier = Modifier.size(24.dp).clickable { onNewChat() })
                }
            },
        ) { padding -> Box(Modifier.padding(padding)) { chat() } }
    }
}

@Composable
private fun SidebarButton(icon: ImageVector, label: String, onClick: () -> Unit) {
    val dark = isSystemInDarkTheme()
    Row(
        Modifier.padding(12.dp).fillMaxWidth().clip(RoundedCornerShape(8.dp))
            .background(if (dark) GptColors.SidebarActiveDk else GptColors.SidebarActive)
            .clickable { onClick() }.padding(horizontal = 12.dp, vertical = 10.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(10.dp),
    ) {
        Icon(icon, null, tint = if (dark) GptColors.DarkTextPrimary else GptColors.TextPrimary,
            modifier = Modifier.size(18.dp))
        Text(label, style = GptText.Button,
            color = if (dark) GptColors.DarkTextPrimary else GptColors.TextPrimary)
    }
}

Bottom sheets (model picker, attachment sheet) use Material 3 ModalBottomSheet. Android has no live blur; ChatGPT’s sheets use a plain dim scrim anyway, so use scrimColor directly — nothing to approximate.

6. Motion

ChatGPT is restrained and editorial — streaming text has no animation (append tokens character-by-character).

Moment Compose recipe
Message send user bubble slideInVertically + fadeIn 250ms ease-out; send button scale 0.9 → 1.0 spring
Typing indicator rememberInfiniteTransition 3 dots, alpha 0.3 ↔ 1.0 over 600ms, staggered 200ms
Assistant streaming none — append to the string as tokens arrive
Regenerate response fadeOut 200ms, new response streams in
Voice mode entry scaleIn(0.9f) + fadeIn(tween(400)) spring; HapticFeedbackType.LongPress (.impact solid)
Voice sphere Animatable scale 0.95 ↔ 1.05 over 2s + sine deformation on the Canvas radius
Drawer slide ModalNavigationDrawer edge-swipe; spring(dampingRatio = 0.8f) on release
Copy button swap icon + label to “Copied!” for ~1s then revert
Thumbs feedback fill icon + scale 1.0 → 1.15 → 1.0 spring; HapticFeedbackType.TextHandleMove (.soft)
// Message-send bubble entrance
AnimatedVisibility(
    visible = true,
    enter = slideInVertically(tween(250)) { it / 2 } + fadeIn(tween(250)),
) { UserMessageBubble(text) }

Honor Reduce Motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0f, render the voice sphere static (no pulse/morph), the typing indicator as 3 static dots with a “Generating…” label, and skip the send-button scale — keep haptics. For richer haptics use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(8, ...) to approximate iOS’s .soft thumbs feedback.

7. Icons

ChatGPT uses simple glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. The ChatGPT sparkle/blossom logomark is proprietary — ship it as a vector drawable and load via ImageVector.vectorResource(R.drawable.…); Icons.Filled.AutoAwesome is a reasonable stand-in for the assistant avatar in prototypes.

Purpose SF Symbol (iOS) Material Icon (Compose)
Send arrow.up Icons.Filled.ArrowUpward
Stop generating stop.fill Icons.Filled.Stop
Voice (mic) mic.fill / waveform Icons.Filled.Mic
Attach (+) plus Icons.Filled.Add
Web search globe Icons.Filled.Public
New chat square.and.pencil Icons.Filled.Edit
Sidebar toggle sidebar.leading Icons.Filled.Menu
Regenerate arrow.triangle.2.circlepath Icons.Filled.Refresh
Copy doc.on.doc Icons.Filled.ContentCopy
Thumbs up hand.thumbsup / .fill Icons.Filled.ThumbUp
Thumbs down hand.thumbsdown / .fill Icons.Filled.ThumbDown
Sparkle (assistant avatar) sparkles Icons.Filled.AutoAwesome (or custom logomark)
Model chip chevron chevron.down Icons.Filled.KeyboardArrowDown
Close (X) xmark Icons.Filled.Close
Search magnifyingglass Icons.Filled.Search
Copied confirmation checkmark Icons.Filled.Check

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; modern motion + audio comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+. Söhne requires a commercial license from Klim Type Foundry — Inter (OFL) or Roboto (system) are acceptable free fallbacks.
  • Edge-to-edge: call enableEdgeToEdge() in the Activity; system-bar icon contrast must follow the theme (dark icons on the light canvas, light icons on #212121). The composer rises with the IME via Modifier.imePadding(); voice mode deliberately ignores insets (full-bleed sphere).
  • Font scaling: sp honors the user’s font scale automatically — keep it on chat body, markdown headings, user-bubble text, code blocks (code stays monospace at XL). Pin layout-sensitive text (model chip, sidebar section titles, timestamps, code-block language label) by deriving from dp or wrapping in CompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)).
  • TalkBack: label user turns “You: {content}” and assistant turns “ChatGPT: {content}”; announce voice mode as “Voice conversation active, ChatGPT is listening”. Expose the feedback row via Modifier.semantics custom actions (“Regenerate”, “Copy”, “Good response”, “Bad response”). Every icon-only control needs a contentDescription.
  • Touch targets: Material guidance is 48.dp minimum. The send/voice 32dp circles sit in 40–48dp composer hit areas; the feedback-row icons are a tight 32dp per the iOS spec — for strict accessibility compliance bump their hit area to 48dp via padding while keeping the 16dp glyph.
  • Contrast: #0D0D0D on #FFFFFF and #ECECEC on #212121 exceed WCAG AA at all sizes. #676767 secondary on white meets AA at 14sp+; in High Contrast mode boost it toward #3E3E3E. The link blue #2A7FFF on white passes AA for text.
  • Dynamic color: do not enable Material You dynamicColorScheme() — ChatGPT’s identity is the deliberate monochrome (white/charcoal canvas, ink text, blue only on links and the voice sphere) regardless of wallpaper. (Material You suits Google/Material-first apps; ChatGPT is a strong-brand exception.) Unlike most strong-brand apps, ChatGPT does support both light and dark equally — switch on isSystemInDarkTheme(), never tint from the wallpaper.
All mobile designs →

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