coderef

Snapchat

Snapchat's iOS app is a camera that happens to have messaging, maps, and a video feed attached — not the other way around.
DownloadRaw
Snapchat

Build with the Snapchat look.

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

Palette

At a glance

Theme
light
Typefaces
Avenir Next Bold, Avenir Next Medium, Avenir Next Heavy
Sections
8
Length
5,362 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Snapchat

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

Snapchat (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Snapchat’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 camera-first black canvas, the aggressive Snap Yellow, the 82dp concentric capture button, the color-coded snap-type inbox, bitmoji-as-avatar) while making everything idiomatic Android — a HorizontalPager for the 5-screen swipe nav instead of a paged TabView, pointerInput for the tap/long-press/double-tap capture gesture, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for bitmoji avatars and story-preview imagery. (No androidx.palette — Snapchat does no color extraction; the camera feed is the content surface.)

1. Color Tokens

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

object SnapColors {
    // Canvas & Surfaces — camera-default dark
    val Canvas    = Color(0xFF000000)
    val Surface1  = Color(0xFF1A1A1A) // received chat bubble, discover cards, sheets
    val Surface2  = Color(0xFF2C2C2C) // sent chat bubble, selected rows
    val Divider   = Color(0xFF333333)

    // Canvas (Light — limited: settings, memories)
    val LightCanvas   = Color(0xFFFFFFFF)
    val LightSurface1 = Color(0xFFF2F2F2)

    // Text
    val TextPrimary       = Color(0xFFFFFFFF)
    val TextPrimaryLight  = Color(0xFF000000)
    val TextSecondary     = Color(0xFF8A8A8F)
    val TextTertiary      = Color(0xFF555555)

    // Brand — the aggressive single accent
    val Yellow        = Color(0xFFFFFC00)
    val YellowPressed = Color(0xFFE6E300)

    // Snap type colors (color-coded inbox)
    val PhotoRed    = Color(0xFFFF2E3D)
    val VideoPurple = Color(0xFF9B51FF)
    val ChatBlue    = Color(0xFF4DA7FF)
    val AudioGreen  = Color(0xFF4CD964)

    // Semantic
    val ErrorRed     = Color(0xFFFF3B30)
    val SuccessGreen = Color(0xFF00D873)
    val LiveRed      = Color(0xFFFF2E3D)

    // Snap Map heat
    val MapWarm = Color(0xFFFF9A1F)
}

Snapchat’s gravity is dark/camera-first — pure black, never a warm dark. Wire a Material 3 darkColorScheme; do not provide a light scheme as default (light appears only inside settings/memories). primary is Snap Yellow; onPrimary is black (yellow CTAs carry black text/icons). The snap-type colors are explicit tokens, never theme roles.

// ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable

private val SnapScheme = darkColorScheme(
    primary        = SnapColors.Yellow,
    onPrimary      = SnapColors.Canvas,      // intentional: black on yellow
    background     = SnapColors.Canvas,
    onBackground   = SnapColors.TextPrimary,
    surface        = SnapColors.Surface1,
    onSurface      = SnapColors.TextPrimary,
    surfaceVariant = SnapColors.Surface2,
    outline        = SnapColors.Divider,
    error          = SnapColors.ErrorRed,
)

@Composable
fun SnapTheme(content: @Composable () -> Unit) =
    MaterialTheme(colorScheme = SnapScheme, typography = SnapTypography, content = content)

2. Typography

Avenir Next is the iOS system face; on Android it is not bundled. Drop the licensed Avenir Next TTFs in res/font/ and build a FontFamily. Fall back to the system font (Roboto) — a warm humanist geometric sans is the closest free substitute. Snapchat uses Medium (500) and Bold (700) only, with Heavy (900) on splash/streak hero — never regular/light/thin.

// ui/theme/SnapType.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 AvenirNext = FontFamily(
    Font(R.font.avenir_next_medium, FontWeight.Medium), // 500 — chat, captions
    Font(R.font.avenir_next_bold,   FontWeight.Bold),   // 700 — names, headings, buttons
    Font(R.font.avenir_next_heavy,  FontWeight.Black),  // 900 — splash / streak hero only
)

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object SnapText {
    val SplashTitle    = TextStyle(AvenirNext, fontWeight = FontWeight.Black,  fontSize = 48.sp, lineHeight = 48.sp, letterSpacing = (-0.5).sp)
    val ScreenTitle    = TextStyle(AvenirNext, fontWeight = FontWeight.Bold,   fontSize = 24.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
    val SectionHeader  = TextStyle(AvenirNext, fontWeight = FontWeight.Bold,   fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.1).sp)
    val ChatRowName    = TextStyle(AvenirNext, fontWeight = FontWeight.Bold,   fontSize = 16.sp, lineHeight = 20.sp)
    val ChatMessage    = TextStyle(AvenirNext, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 21.sp)
    val ChatStatus     = TextStyle(AvenirNext, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 16.sp)
    val StoryName      = TextStyle(AvenirNext, fontWeight = FontWeight.Bold,   fontSize = 14.sp, lineHeight = 17.sp)
    val Timestamp      = TextStyle(AvenirNext, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 14.sp)
    val StreakCount    = TextStyle(AvenirNext, fontWeight = FontWeight.Bold,   fontSize = 18.sp, lineHeight = 18.sp)
    val HudLabel       = TextStyle(AvenirNext, fontWeight = FontWeight.Bold,   fontSize = 13.sp, lineHeight = 13.sp, letterSpacing = 0.3.sp)
    val ButtonPrimary  = TextStyle(AvenirNext, fontWeight = FontWeight.Bold,   fontSize = 16.sp, lineHeight = 16.sp, letterSpacing = 0.2.sp)
    val LensLabel      = TextStyle(AvenirNext, fontWeight = FontWeight.Bold,   fontSize = 13.sp, lineHeight = 13.sp, letterSpacing = 0.3.sp)
    val BitmojiCallout = TextStyle(AvenirNext, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 18.sp)
    val SpotlightCap   = TextStyle(AvenirNext, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 18.sp)
    val DiscoverTitle  = TextStyle(AvenirNext, fontWeight = FontWeight.Bold,   fontSize = 16.sp, lineHeight = 19.sp, letterSpacing = (-0.1).sp)
}

val SnapTypography = Typography(
    displayMedium = SnapText.SplashTitle,
    headlineSmall = SnapText.ScreenTitle,
    titleMedium   = SnapText.ChatRowName,
    bodyMedium    = SnapText.ChatMessage,
    labelMedium   = SnapText.Timestamp,
)

3. Signature Components

Snap Capture Button (the 82dp concentric double-circle)

The most recognizable component in the app. Outer 82dp / 6dp Snap-Yellow ring + inner 64dp white circle. Tap = photo; long-press = video (inner fills yellow, outer ring becomes a clockwise progress arc up to 60s); double-tap = camera flip. One pointerInput block disambiguates all three.

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay

@Composable
fun SnapCaptureButton(
    isRecording: Boolean,
    recordProgress: Float, // 0f..1f over 60s
    onPhoto: () -> Unit,
    onVideoStart: () -> Unit,
    onVideoStop: () -> Unit,
    onFlip: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val haptics = LocalHapticFeedback.current
    val innerScale = remember { Animatable(1f) }

    androidx.compose.foundation.layout.Box(
        modifier = modifier
            .size(82.dp)
            .pointerInput(Unit) {
                awaitPointerEventScope {
                    while (true) {
                        val down = awaitFirstDown()
                        val downTime = System.currentTimeMillis()
                        var longFired = false
                        // long-press → video
                        val longJob = launchAndDelay(350) {
                            longFired = true
                            haptics.performHapticFeedback(HapticFeedbackType.LongPress) // impact medium
                            onVideoStart()
                        }
                        val up = waitForUpOrCancellation()
                        longJob.cancel()
                        if (longFired) {
                            onVideoStop()
                        } else if (up != null) {
                            val now = System.currentTimeMillis()
                            if (now - lastTap < 300) {           // double-tap → flip
                                haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
                                onFlip()
                            } else {                              // single tap → photo
                                haptics.performHapticFeedback(HapticFeedbackType.LongPress) // impact soft
                                onPhoto()
                            }
                            lastTap = now
                        }
                    }
                }
            },
        contentAlignment = androidx.compose.ui.Alignment.Center,
    ) {
        // Outer yellow ring + (when recording) clockwise progress arc
        Canvas(Modifier.size(82.dp)) {
            val stroke = 6.dp.toPx()
            drawCircle(SnapColors.Yellow, radius = (size.minDimension - stroke) / 2f, style = Stroke(stroke))
            if (isRecording) {
                drawArc(
                    color = SnapColors.Yellow,
                    startAngle = -90f,
                    sweepAngle = 360f * recordProgress,
                    useCenter = false,
                    topLeft = Offset(stroke / 2f, stroke / 2f),
                    size = Size(size.width - stroke, size.height - stroke),
                    style = Stroke(stroke),
                )
            }
        }
        // Inner circle — white, or yellow while recording
        androidx.compose.foundation.layout.Box(
            Modifier
                .size(64.dp)
                .scale(innerScale.value)
                .clip(CircleShape)
                .background(if (isRecording) SnapColors.Yellow else SnapColors.TextPrimary)
        )
    }

    // photo-tap squish
    LaunchedEffect(Unit) { /* drive innerScale 0.92 → 1.0 from onPhoto in real code */ }
}

// (helpers `lastTap`, `launchAndDelay` are sketches — wire to a rememberCoroutineScope in production)

Chat Inbox Row (color-coded snap type)

72dp tall on black, 1dp divider. 48dp circular bitmoji, name, then the snap-type indicator (filled square unopened / outline opened) + status, with timestamp + 🔥 streak trailing.

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage

enum class SnapType { PHOTO, VIDEO, CHAT, AUDIO, NONE }
enum class SnapDirection { INCOMING, OUTGOING }

@Composable
fun SnapChatRow(
    name: String,
    bitmojiUrl: String,
    status: String,        // "Received · 2m ago"
    timestamp: String,     // "2m"
    snapType: SnapType,
    direction: SnapDirection,
    isUnread: Boolean,
    streakDays: Int?,
    modifier: Modifier = Modifier,
) {
    Column(modifier) {
        Row(
            Modifier.fillMaxWidth().height(72.dp).padding(horizontal = 16.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            AsyncImage(
                model = bitmojiUrl, contentDescription = null, contentScale = ContentScale.Crop,
                modifier = Modifier.size(48.dp).clip(CircleShape),
            )
            Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
                Text(name, style = SnapText.ChatRowName, color = SnapColors.TextPrimary)
                Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
                    if (snapType != SnapType.NONE) {
                        SnapTypeIndicator(snapType, direction, isUnread)
                    }
                    Text(status, style = SnapText.ChatStatus, color = SnapColors.TextSecondary)
                }
            }
            Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
                Text(timestamp, style = SnapText.Timestamp, color = SnapColors.TextSecondary)
                streakDays?.let {
                    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
                        Text("🔥")
                        Text("$it", style = SnapText.StreakCount, color = SnapColors.TextPrimary)
                    }
                }
            }
        }
        Spacer(Modifier.fillMaxWidth().height(1.dp).background(SnapColors.Divider))
    }
}

@Composable
fun SnapTypeIndicator(type: SnapType, direction: SnapDirection, isUnread: Boolean) {
    val color = when (type) {
        SnapType.PHOTO -> SnapColors.PhotoRed
        SnapType.VIDEO -> SnapColors.VideoPurple
        SnapType.CHAT  -> SnapColors.ChatBlue
        SnapType.AUDIO -> SnapColors.AudioGreen
        SnapType.NONE  -> SnapColors.TextSecondary
    }
    Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) {
        Box(
            Modifier
                .size(16.dp)
                .clip(RoundedCornerShape(2.dp))
                .then(if (isUnread) Modifier.background(color) else Modifier)
                .border(1.5.dp, color, RoundedCornerShape(2.dp))
        )
        Icon(
            if (direction == SnapDirection.INCOMING) Icons.Filled.ArrowDownward else Icons.Filled.ArrowUpward,
            contentDescription = null,
            tint = if (isUnread) Color.White else color,
            modifier = Modifier.size(8.dp),
        )
    }
}

import androidx.compose.foundation.border

Story Thumbnail (pulsing unread ring)

120dp × 200dp, 16dp corner. 3dp ring: yellow (unread, subtle 2s opacity pulse), gray (read), red (live). Bitmoji top-left, creator name bottom-left with a drop shadow for legibility on imagery.

import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.draw.drawBehind

enum class StoryState { UNREAD, READ, LIVE }

@Composable
fun SnapStoryThumb(
    creatorName: String,
    bitmojiUrl: String,
    previewUrl: String,
    state: StoryState,
    modifier: Modifier = Modifier,
) {
    val ringColor = when (state) {
        StoryState.UNREAD -> SnapColors.Yellow
        StoryState.READ   -> SnapColors.TextTertiary
        StoryState.LIVE   -> SnapColors.LiveRed
    }
    val pulse = rememberInfiniteTransition(label = "ring")
    val ringAlpha by pulse.animateFloat(
        1f, 0.7f,
        infiniteRepeatable(tween(2000), RepeatMode.Reverse),
        label = "ringAlpha",
    )

    Box(
        modifier
            .size(width = 120.dp, height = 200.dp)
            .clip(RoundedCornerShape(16.dp))
            .drawBehind { } // image drawn by AsyncImage below
    ) {
        AsyncImage(
            model = previewUrl, contentDescription = "$creatorName story", contentScale = ContentScale.Crop,
            modifier = Modifier.matchParentSize(),
        )
        Box(
            Modifier
                .matchParentSize()
                .border(
                    width = 3.dp,
                    color = ringColor.copy(alpha = if (state == StoryState.UNREAD) ringAlpha else 1f),
                    shape = RoundedCornerShape(16.dp),
                )
        )
        AsyncImage(
            model = bitmojiUrl, contentDescription = null, contentScale = ContentScale.Crop,
            modifier = Modifier.padding(8.dp).size(40.dp).clip(CircleShape).align(Alignment.TopStart),
        )
        Text(
            creatorName,
            style = SnapText.StoryName,
            color = Color.White,
            modifier = Modifier
                .align(Alignment.BottomStart)
                .padding(10.dp)
                .graphicsLayer { shadowElevation = 2f }, // ~rgba(0,0,0,0.4) text shadow proxy
        )
    }
}

Chat Bubble (dark gray, not iMessage-colored)

Both sent and received are dark-gray variants — never blue. 20dp corner, no tail, max 75% width.

@Composable
fun SnapChatBubble(text: String, fromMe: Boolean, modifier: Modifier = Modifier) {
    Row(modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
        if (fromMe) Spacer(Modifier.weight(1f).widthIn(min = 60.dp))
        Text(
            text,
            style = SnapText.ChatMessage,
            color = SnapColors.TextPrimary,
            modifier = Modifier
                .widthIn(max = (LocalConfiguration.current.screenWidthDp * 0.75f).dp)
                .clip(RoundedCornerShape(20.dp))
                .background(if (fromMe) SnapColors.Surface2 else SnapColors.Surface1)
                .padding(horizontal = 14.dp, vertical = 10.dp),
        )
        if (!fromMe) Spacer(Modifier.weight(1f).widthIn(min = 60.dp))
    }
}

import androidx.compose.ui.platform.LocalConfiguration

4. Snapchat-Specific Feature: the Chromeless Camera HUD

The signature: the app opens to a full-bleed live viewfinder with no UI frame — controls float chromelessly in the corners with a drop shadow for legibility over any scene. Above the capture button is the lens carousel (active lens scales 60dp → 72dp with a 3dp yellow ring).

import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.unit.dp

@Composable
fun SnapCameraHUD(
    cameraFeed: @Composable () -> Unit, // your CameraX PreviewView wrapped in AndroidView
) {
    var flashOn by remember { mutableStateOf(false) }
    var selectedLens by remember { mutableStateOf(2) }
    var isRecording by remember { mutableStateOf(false) }
    var progress by remember { mutableStateOf(0f) }
    val lenses = listOf("😎", "🎭", "🐶", "😂", "✨")

    Box(Modifier.fillMaxSize().background(SnapColors.Canvas)) {
        cameraFeed() // edge-to-edge, no frame

        Column(Modifier.fillMaxSize()) {
            // Top floating HUD
            Row(
                Modifier.fillMaxWidth().padding(16.dp),
                horizontalArrangement = Arrangement.SpaceBetween,
            ) {
                HudIcon(Icons.Filled.AccountCircle)
                Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                    HudIcon(
                        if (flashOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff,
                        tint = if (flashOn) SnapColors.Yellow else Color.White,
                    ) { flashOn = !flashOn }
                    HudIcon(Icons.Filled.FlipCameraAndroid)
                    HudIcon(Icons.Filled.Search)
                }
            }

            Spacer(Modifier.weight(1f))

            LensCarousel(lenses, selectedLens) { selectedLens = it }
            Spacer(Modifier.height(16.dp))

            Row(
                Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 16.dp),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically,
            ) {
                HudIcon(Icons.Filled.PhotoLibrary, size = 32.dp)
                SnapCaptureButton(
                    isRecording = isRecording,
                    recordProgress = progress,
                    onPhoto = {},
                    onVideoStart = { isRecording = true },
                    onVideoStop = { isRecording = false },
                    onFlip = {},
                )
                HudIcon(Icons.AutoMirrored.Filled.Chat, size = 32.dp)
            }
        }
    }
}

@Composable
fun HudIcon(
    icon: androidx.compose.ui.graphics.vector.ImageVector,
    size: androidx.compose.ui.unit.Dp = 28.dp,
    tint: Color = Color.White,
    onClick: () -> Unit = {},
) {
    Box(
        Modifier.size(44.dp).clickableNoRipple(onClick),
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            icon, contentDescription = null, tint = tint,
            modifier = Modifier
                .size(size)
                .graphicsLayer { shadowElevation = 2f }, // legibility over any scene
        )
    }
}

@Composable
fun LensCarousel(lenses: List<String>, selected: Int, onSelect: (Int) -> Unit) {
    LazyRow(
        horizontalArrangement = Arrangement.spacedBy(12.dp),
        contentPadding = PaddingValues(horizontal = 16.dp),
        modifier = Modifier.fillMaxWidth(),
    ) {
        itemsIndexed(lenses) { i, lens ->
            val active = i == selected
            val s by animateFloatAsState(if (active) 72f else 60f, label = "lensSize")
            Box(
                Modifier
                    .size(s.dp)
                    .clip(CircleShape)
                    .background(SnapColors.Surface2)
                    .then(if (active) Modifier.border(3.dp, SnapColors.Yellow, CircleShape) else Modifier)
                    .clickableNoRipple { onSelect(i) },
                contentAlignment = Alignment.Center,
            ) { Text(lens, fontSize = (if (active) 32 else 28).sp) }
        }
    }
}

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.ui.composed
fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier = composed {
    clickable(remember { MutableInteractionSource() }, indication = null, onClick = onClick)
}

5. Navigation — swipe-between-five-screens (NOT a tab bar)

Snapchat has no traditional tab bar. iOS uses a paged TabView; the Compose equivalent is a HorizontalPager with the Camera centered (page 2) as the default, plus a minimal indicator strip (translucent over the camera, solid black elsewhere — Android has no live blur).

import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState

@Composable
fun SnapRootSwipeNav() {
    val pager = rememberPagerState(initialPage = 2, pageCount = { 5 }) // Camera = 2, the default

    Box(Modifier.fillMaxSize()) {
        HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
            when (page) {
                0 -> SnapMapScreen()
                1 -> ChatListScreen()
                2 -> SnapCameraHUD(cameraFeed = { /* CameraX preview */ })
                3 -> StoriesScreen()
                4 -> SpotlightScreen()
            }
        }
        SnapNavIndicator(
            selected = pager.currentPage,
            overCamera = pager.currentPage == 2,
            modifier = Modifier.align(Alignment.BottomCenter),
        )
    }
}

@Composable
fun SnapNavIndicator(selected: Int, overCamera: Boolean, modifier: Modifier = Modifier) {
    val icons = listOf(
        Icons.Filled.Map, Icons.AutoMirrored.Filled.Chat, Icons.Filled.CameraAlt,
        Icons.Filled.PlayArrow, Icons.Filled.Diamond,
    )
    Row(
        modifier
            .fillMaxWidth()
            .background(if (overCamera) Color.Black.copy(alpha = 0.5f) else SnapColors.Canvas)
            .height(56.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        icons.forEachIndexed { i, icon ->
            Icon(
                icon, contentDescription = null,
                tint = if (i == selected) Color.White else SnapColors.TextTertiary,
                modifier = Modifier.weight(1f).size(28.dp),
            )
        }
    }
}

HorizontalPager gives the 300ms spring page transition; set pageSpacing and rely on the default fling for the rubber-band edge feel. No labels — icons only.

6. Motion

Moment Compose recipe
Screen swipe (5-screen) HorizontalPager default spring (~300ms) + fling; HapticFeedbackType.TextHandleMove on edge resistance
Capture tap (photo) inner-circle Animatable 0.92 → 1.0 spring(stiffness = 500f) + white-flash Box 100ms + HapticFeedbackType.LongPress (soft)
Capture hold (video) outer arc drawArc driven by a 60s Animatable/tween; LongPress on start, soft tick per 1s
Camera flip (double-tap) shutter icon rotate 180° over 400ms + HapticFeedbackType.TextHandleMove
Lens swipe/select animateFloatAsState 60dp → 72dp + yellow-ring fadeIn; AR overlay cross-fade 200ms
Snap send preview slideOutHorizontally { -it } 300ms + confetti Canvas/Animatable + HapticFeedbackType.Confirm
Streak save flame Animatable 1.0 → 1.3 → 1.0 + “+1 🔥” offset/fadeOut 600ms + success haptic
Story ring pulse rememberInfiniteTransition opacity 1.0 ↔ 0.7 over 2s (unread only)
// Streak-save bounce + floating "+1 🔥"
@Composable
fun StreakSaveBurst(trigger: Any) {
    val scale = remember { Animatable(1f) }
    val rise = remember { Animatable(0f) }
    LaunchedEffect(trigger) {
        scale.animateTo(1.3f, spring(dampingRatio = 0.5f)); scale.animateTo(1f, spring())
        rise.animateTo(1f, tween(600))
    }
    Box {
        Text("🔥", Modifier.scale(scale.value), style = SnapText.StreakCount)
        Text(
            "+1 🔥",
            Modifier
                .offset(y = (-20).dp * rise.value)
                .graphicsLayer { alpha = 1f - rise.value },
            style = SnapText.HudLabel,
            color = SnapColors.Yellow,
        )
    }
}

import androidx.compose.foundation.layout.offset

Haptics: prefer LocalHapticFeedback. Photo capture = LongPress (soft impact), video start = LongPress (medium), camera flip / screen-edge / lens = TextHandleMove, streak save / snap sent = HapticFeedbackType.Confirm. The capture haptic should also pair with a shutter sound. Honor reduce-motion: disable the story-ring pulse and skip the flip-spin while still firing haptics.

7. Icons

Snapchat uses custom glyphs extensively (ghost logo, capture button is hand-drawn in §3); the closest first-party set is androidx.compose.material:material-icons-extended. The ghost logo and bitmoji ship as assets (vector drawable / Coil-loaded). Bitmoji always replaces a generic person icon.

Purpose SF Symbol (iOS) Material Icon (Compose)
Camera (nav) camera.fill Icons.Filled.CameraAlt
Chat (nav) bubble.left.fill Icons.AutoMirrored.Filled.Chat
Map (nav) map.fill Icons.Filled.Map
Stories (nav) play.rectangle.fill Icons.Filled.PlayArrow
Spotlight (nav) diamond.fill Icons.Filled.Diamond
Memories (HUD) photo.stack.fill Icons.Filled.PhotoLibrary
Flash on bolt.fill Icons.Filled.FlashOn
Flash off bolt.slash.fill Icons.Filled.FlashOff
Camera flip arrow.triangle.2.circlepath.camera.fill Icons.Filled.FlipCameraAndroid
Search magnifyingglass Icons.Filled.Search
Send paperplane.fill Icons.AutoMirrored.Filled.Send
New chat square.and.pencil Icons.Filled.Edit
Profile person.crop.circle.fill (or bitmoji) Icons.Filled.AccountCircle (or bitmoji)
Snap incoming arrow.down in square Icons.Filled.ArrowDownward (in 16dp square)
Snap outgoing arrow.up in square Icons.Filled.ArrowUpward (in 16dp square)
Microphone (audio) mic.fill Icons.Filled.Mic
Sticker face.smiling Icons.Filled.AddReaction
Streak / BFF / birthday 🔥 / 💛 / 🎂 emoji glyphs (not icons)

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (CameraX + modern motion are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+. The capture button and progress arc are hand-drawn Canvas — no extra dependency.
  • Edge-to-edge: call enableEdgeToEdge(). The camera is edge-to-edge by design (Modifier.fillMaxSize() + ignoresSafeArea equivalent — no insets on the viewfinder). Apply insets only to the floating HUD icons (clear the status bar / Dynamic-Island-equivalent cutout) and the bottom nav strip (clear gesture nav). Capture button sits ~20.dp above the nav strip.
  • Font scaling: sp honors user scale on chat messages, row names, bios, settings. Pin camera HUD labels, streak counts, lens labels, timestamps, and story-thumbnail names to fixed sizes (layout-sensitive) via a fixed-fontScale LocalDensity.
  • TalkBack: every floating HUD icon needs an explicit contentDescription (“Flash, off”, “Camera, switch to front”). The capture button announces “Snap camera, double tap to capture photo, press and hold to record video”. The swipe nav is disorienting for screen readers — provide a rotor/semantics “Go to screen” action listing Map / Chat / Camera / Stories / Spotlight as discrete destinations. Bitmoji images are decorative: Modifier.semantics { hideFromAccessibility() } (or contentDescription = null) and rely on the adjacent name.
  • Reduce motion: respect the accessibility setting — disable the story-ring pulse, skip the camera-flip spin and the streak burst, but still fire haptics. Reduce transparency: the translucent nav indicator falls back to solid #000000.
  • Alternatives for inaccessible gestures: long-press-for-video is not screen-reader-operable — provide an explicit “Record video” mode toggle. Flash/Live states must always carry a text label, never color alone.
  • Contrast: white on #000000 is maximum; #8A8A8F secondary on black passes AA at 13sp+. The chromeless HUD relies on the drop shadow over arbitrary camera scenes — offer an accessibility “opaque chrome background” toggle that puts a semi-transparent pill behind each icon.
  • Dynamic color: do not enable Material You dynamicColorScheme() — Snap Yellow is an intentionally aggressive fixed brand signal flare, and the snap-type colors (red/purple/blue/green) are a semantic at-a-glance language. Both must be identical regardless of wallpaper.
All mobile designs →

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