coderef

TikTok

TikTok's iOS app is a full-bleed black canvas that exists to get out of the way of a single vertical video. From the moment you open the app you are dropped into the For You feed: a 9:16 clip fills the entire screen edge-to-edge…
DownloadRaw
TikTok

Build with the TikTok look.

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

Palette

At a glance

Theme
dark
Typefaces
Proxima Nova
Sections
8
Length
4,377 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · TikTok

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

TikTok (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports TikTok’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 true-black canvas, chromatic-aberration logo/Create button, rose-as-the-verb, the full-bleed 9:16 feed with a floating right-side action rail) while making everything idiomatic Android — a custom bottom bar instead of a UITabBar, an AccelerateDecelerate VerticalPager instead of a paged TabView, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for avatars/artwork, and androidx.media3:media3-exoplayer for the video feed (wrap a PlayerView in AndroidView). No color extraction — TikTok is monochromatically cyan + rose on black, so androidx.palette is not required.

1. Color Tokens

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

object TikTokColors {
    // Canvas & Surfaces
    val Canvas     = Color(0xFF010101) // true black — a hair warmer than #000000
    val Surface    = Color(0xFF161823) // DMs, sheets
    val InputField = Color(0xFF2F2F2F) // comment compose, search

    // Brand
    val Rose = Color(0xFFFE2C55) // the verb — Like, Follow, scrubber fill
    val Cyan = Color(0xFF25F4EE) // the accent — Create button, chromatic aberration

    // Text
    val TextPrimary   = Color(0xFFFFFFFF)
    val TextSecondary = Color(0xFFE5E5E5)               // hashtag counts
    val TextTertiary  = Color(0xFFFFFFFF).copy(alpha = 0.6f) // placeholder, meta
    val TextDisabled  = Color(0xFFFFFFFF).copy(alpha = 0.3f)

    // Overlays
    val FollowerGray  = Color(0xFFFFFFFF).copy(alpha = 0.15f) // "Following" pill bg
    val ScrimLight    = Color(0xFF000000).copy(alpha = 0.25f) // icon shadow
    val ScrimMedium   = Color(0xFF000000).copy(alpha = 0.4f)  // text-on-video shadow
    val ScrimHeavy    = Color(0xFF000000).copy(alpha = 0.6f)  // sheet dim
    val ScrubberTrack = Color(0xFFFFFFFF).copy(alpha = 0.3f)
}

TikTok has no light mode — every screen is dark. Supply only a Material 3 darkColorScheme. primary is rose (the verb); secondary is cyan (the Create accent).

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

private val TikTokScheme = darkColorScheme(
    primary        = TikTokColors.Rose,
    onPrimary      = TikTokColors.TextPrimary,
    secondary      = TikTokColors.Cyan,
    background     = TikTokColors.Canvas,
    onBackground   = TikTokColors.TextPrimary,
    surface        = TikTokColors.Surface,
    onSurface      = TikTokColors.TextPrimary,
    surfaceVariant = TikTokColors.InputField,
    error          = TikTokColors.Rose, // TikTok reuses rose for error
)

@Composable
fun TikTokTheme(content: @Composable () -> Unit) =
    MaterialTheme(colorScheme = TikTokScheme, typography = TikTokTypography, content = content)
// Dark-only — never provide a lightColorScheme.

2. Typography

Proxima Nova is a licensed third-party face (Mark Simonson). Drop the TTF/OTF files in res/font/ (lowercase, snake_case). Fall back to the system font (Roboto) — its geometric-humanist tone is the closest free substitute on Android.

// ui/theme/TikTokType.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 ProximaNova = FontFamily(
    Font(R.font.proximanova_regular,  FontWeight.Normal),   // 400
    Font(R.font.proximanova_medium,   FontWeight.Medium),   // 500
    Font(R.font.proximanova_semibold, FontWeight.SemiBold), // 600
    Font(R.font.proximanova_bold,     FontWeight.Bold),     // 700
)

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1, weights preserved)
object TikTokText {
    val DisplayName   = TextStyle(ProximaNova, fontWeight = FontWeight.Bold,     fontSize = 24.sp, lineHeight = 29.sp, letterSpacing = (-0.2).sp)
    val SheetTitle    = TextStyle(ProximaNova, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 21.sp, letterSpacing = (-0.1).sp)
    val Username      = TextStyle(ProximaNova, fontWeight = FontWeight.Bold,     fontSize = 16.sp, lineHeight = 19.sp)
    val Caption       = TextStyle(ProximaNova, fontWeight = FontWeight.Normal,   fontSize = 15.sp, lineHeight = 20.sp)
    val CaptionHashtag = TextStyle(ProximaNova, fontWeight = FontWeight.Bold,    fontSize = 15.sp, lineHeight = 20.sp)
    val UsernameList  = TextStyle(ProximaNova, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 19.sp)
    val ActionCount   = TextStyle(ProximaNova, fontWeight = FontWeight.Bold,     fontSize = 13.sp, lineHeight = 16.sp)
    val Music         = TextStyle(ProximaNova, fontWeight = FontWeight.Medium,   fontSize = 13.sp, lineHeight = 16.sp)
    val Body          = TextStyle(ProximaNova, fontWeight = FontWeight.Normal,   fontSize = 14.sp, lineHeight = 20.sp)
    val Meta          = TextStyle(ProximaNova, fontWeight = FontWeight.Normal,   fontSize = 12.sp, lineHeight = 16.sp)
    val Tab           = TextStyle(ProximaNova, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
    val ButtonPrimary = TextStyle(ProximaNova, fontWeight = FontWeight.Bold,     fontSize = 16.sp, lineHeight = 16.sp)
    val ButtonSecondary = TextStyle(ProximaNova, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 14.sp)
    val Follow        = TextStyle(ProximaNova, fontWeight = FontWeight.Bold,     fontSize = 14.sp, lineHeight = 14.sp)
    val Chip          = TextStyle(ProximaNova, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 13.sp)
}

// Map onto Material 3 slots so stock components inherit the brand
val TikTokTypography = Typography(
    headlineMedium = TikTokText.DisplayName,
    titleMedium    = TikTokText.SheetTitle,
    bodyMedium     = TikTokText.Body,
    labelLarge     = TikTokText.ButtonPrimary,
    labelSmall     = TikTokText.Tab,
)

Text-on-Video Shadow Modifier

Every label overlaid on a video needs this — it is non-negotiable for legibility over bright frames.

import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.TextStyle

// For Text, fold the shadow into the TextStyle so it renders per-glyph
fun TextStyle.onVideo(): TextStyle = copy(
    shadow = Shadow(color = TikTokColors.ScrimMedium, offset = Offset(0f, 1f), blurRadius = 4f),
)

// For icons / Boxes, use a drop shadow modifier
fun Modifier.tiktokIconShadow(): Modifier =
    this.shadow(elevation = 3.dp, clip = false, ambientColor = TikTokColors.ScrimLight, spotColor = TikTokColors.ScrimLight)

3. Signature Components

Follow Button (the iconic rose pill — 4dp corner, not full pill)

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.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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 TikTokFollowButton(
    isFollowing: Boolean,
    onToggle: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 0.95f else 1f, spring(dampingRatio = 0.7f), label = "followScale")
    val haptics = LocalHapticFeedback.current

    Box(
        modifier = modifier
            .heightIn(min = 28.dp)
            .scale(scale)
            .clip(RoundedCornerShape(4.dp)) // TikTok-signature "almost-square", NOT a full pill
            .background(if (isFollowing) TikTokColors.FollowerGray else TikTokColors.Rose)
            .clickable(interaction, indication = null) {
                // success haptic on follow, soft on unfollow
                haptics.performHapticFeedback(
                    if (!isFollowing) HapticFeedbackType.LongPress else HapticFeedbackType.TextHandleMove
                )
                onToggle()
            }
            .padding(horizontal = 16.dp, vertical = 6.dp),
        contentAlignment = Alignment.Center,
    ) {
        Text(
            if (isFollowing) "Following" else "Follow",
            style = if (isFollowing) TikTokText.ButtonSecondary else TikTokText.Follow,
            color = TikTokColors.TextPrimary,
        )
    }
}

Chromatic Aberration Modifier (the brand thesis)

iOS stacks three offset copies. Compose: render the same content three times — cyan offset -x, rose offset +x, white core on top — in a Box.

import androidx.compose.ui.graphics.graphicsLayer

@Composable
fun ChromaticAberration(
    offsetDp: Float = 3f,           // scales with size: 1 @16sp, 3 @44dp, 6 @96dp
    content: @Composable (color: androidx.compose.ui.graphics.Color) -> Unit,
) {
    Box {
        Box(Modifier.graphicsLayer { translationX = -offsetDp * density }) { content(TikTokColors.Cyan) }
        Box(Modifier.graphicsLayer { translationX = offsetDp * density }) { content(TikTokColors.Rose) }
        content(TikTokColors.TextPrimary) // white core on top
    }
}

// Usage — the glitch wordmark:
// ChromaticAberration(offsetDp = 6f) { c ->
//     Text("d", style = TextStyle(fontSize = 96.sp, fontWeight = FontWeight.Bold), color = c)
// }

Create Tab Button (chromatic, the centered anchor)

import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Icon

@Composable
fun TikTokCreateButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 0.94f else 1f, label = "createScale")
    val haptics = LocalHapticFeedback.current

    Box(
        modifier = modifier
            .size(width = 44.dp, height = 30.dp)
            .scale(scale)
            .clip(RoundedCornerShape(8.dp))
            .clickable(interaction, indication = null) {
                haptics.performHapticFeedback(HapticFeedbackType.LongPress) // heavy
                onClick()
            },
        contentAlignment = Alignment.Center,
    ) {
        // cyan left edge, rose right edge, white through the middle
        Box(Modifier.matchParentSize().graphicsLayer { translationX = -3 * density }.background(TikTokColors.Cyan))
        Box(Modifier.matchParentSize().graphicsLayer { translationX = 3 * density }.background(TikTokColors.Rose))
        Box(Modifier.matchParentSize().background(TikTokColors.TextPrimary))
        Icon(Icons.Filled.Add, "Create", tint = TikTokColors.Canvas, modifier = Modifier.size(18.dp))
    }
}

Right-Side Action Rail (the load-bearing feed UX)

import androidx.compose.animation.core.LinearEasing
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.foundation.border
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage

@Composable
fun ActionRail(
    avatarUrl: String,
    isFollowed: Boolean,
    onFollow: () -> Unit,
    likeCount: Int,
    isLiked: Boolean,
    onLike: () -> Unit,
    commentCount: Int,
    bookmarkCount: Int,
    shareCount: Int,
    musicArtworkUrl: String,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier.padding(end = 12.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(24.dp),
    ) {
        // Avatar + follow badge
        Box(contentAlignment = Alignment.BottomCenter) {
            AsyncImage(
                model = avatarUrl, contentDescription = null,
                modifier = Modifier.size(48.dp).clip(CircleShape).border(2.dp, TikTokColors.TextPrimary, CircleShape),
                contentScale = ContentScale.Crop,
            )
            if (!isFollowed) {
                Box(
                    Modifier
                        .offset(y = 9.dp)
                        .size(18.dp)
                        .clip(CircleShape)
                        .background(TikTokColors.Rose)
                        .clickable(onClick = onFollow),
                    contentAlignment = Alignment.Center,
                ) { Icon(Icons.Filled.Add, "Follow", tint = TikTokColors.TextPrimary, modifier = Modifier.size(10.dp)) }
            }
        }
        RailIcon(if (isLiked) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, likeCount, if (isLiked) TikTokColors.Rose else TikTokColors.TextPrimary, onLike)
        RailIcon(Icons.Filled.Comment, commentCount, TikTokColors.TextPrimary) {}
        RailIcon(Icons.Filled.Bookmark, bookmarkCount, TikTokColors.TextPrimary) {}
        RailIcon(Icons.Filled.Reply, shareCount, TikTokColors.TextPrimary) {}
        SpinningMusicDisc(musicArtworkUrl)
    }
}

@Composable
private fun RailIcon(icon: ImageVector, count: Int, tint: Color, onClick: () -> Unit) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(4.dp),
        modifier = Modifier.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp).clickable(onClick = onClick),
    ) {
        Icon(icon, null, tint = tint, modifier = Modifier.size(30.dp).tiktokIconShadow())
        Text(formatCount(count), style = TikTokText.ActionCount.onVideo(), color = TikTokColors.TextPrimary)
    }
}

// Counts abbreviate — never raw integers above 1,000
fun formatCount(n: Int): String = when {
    n < 1_000 -> "$n"
    n < 1_000_000 -> "%.1fK".format(n / 1_000.0).removeSuffix(".0K").let { if (it.endsWith("K")) it else it + "K" }
    else -> "%.1fM".format(n / 1_000_000.0).removeSuffix(".0M").let { if (it.endsWith("M")) it else it + "M" }
}

@Composable
private fun SpinningMusicDisc(artworkUrl: String) {
    val t = rememberInfiniteTransition(label = "disc")
    val angle by t.animateFloat(
        0f, 360f,
        infiniteRepeatable(tween(6000, easing = LinearEasing), RepeatMode.Restart), // 360° / 6s
        label = "discAngle",
    )
    Box(
        Modifier.size(44.dp).rotate(angle).clip(CircleShape).background(TikTokColors.Canvas),
        contentAlignment = Alignment.Center,
    ) {
        AsyncImage(
            model = artworkUrl, contentDescription = null,
            modifier = Modifier.size(28.dp).clip(CircleShape),
            contentScale = ContentScale.Crop,
        )
    }
}

Feed Caption Overlay (bold hashtags inline)

import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextOverflow

@Composable
fun CaptionOverlay(
    username: String,
    caption: String,     // "sunset loop #fyp #bayarea"
    musicTitle: String,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier.padding(start = 16.dp, end = 80.dp), // leave room for the action rail
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        Text("@$username", style = TikTokText.Username.onVideo(), color = TikTokColors.TextPrimary)
        Text(
            buildAnnotatedString {
                caption.split(" ").forEachIndexed { i, token ->
                    if (i > 0) append(" ")
                    if (token.startsWith("#")) {
                        withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(token) } // hashtags bold inline
                    } else append(token)
                }
            },
            style = TikTokText.Caption.onVideo(),
            color = TikTokColors.TextPrimary,
            maxLines = 2,
            overflow = TextOverflow.Ellipsis,
        )
        Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
            Icon(Icons.Filled.MusicNote, null, tint = TikTokColors.TextPrimary, modifier = Modifier.size(12.dp))
            Text(musicTitle, style = TikTokText.Music.onVideo(), color = TikTokColors.TextPrimary, maxLines = 1)
        }
    }
}

Video Progress Scrubber

@Composable
fun VideoScrubber(progress: Float, isScrubbing: Boolean, modifier: Modifier = Modifier) {
    val height by animateFloatAsState(if (isScrubbing) 4f else 2f, label = "scrubH")
    Box(
        modifier
            .fillMaxWidth()
            .height(height.dp)
            .background(TikTokColors.ScrubberTrack),
    ) {
        Box(Modifier.fillMaxWidth(progress).height(height.dp).background(TikTokColors.Rose))
    }
}

4. Full-Bleed Vertical Feed + Double-Tap Burst (the signature system)

TikTok’s thesis: a 9:16 video fills the screen edge-to-edge (ignoring safe areas for content), UI floats on top, and a double-tap anywhere spawns a 120dp rose heart. iOS uses a rotated paged TabView + a tap overlay; Compose uses a vertical Pager with the video drawn behind the overlays, plus detectTapGestures(onDoubleTap).

import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntOffset
import kotlinx.coroutines.delay
import kotlin.math.roundToInt

@Composable
fun TikTokFeed(
    pageCount: Int,
    videoContent: @Composable (page: Int) -> Unit, // wrap ExoPlayer PlayerView in AndroidView
    overlayContent: @Composable (page: Int) -> Unit,
) {
    val pagerState = rememberPagerState(pageCount = { pageCount })
    VerticalPager(
        state = pagerState,
        modifier = Modifier.fillMaxSize().background(TikTokColors.Canvas),
    ) { page ->
        Box(Modifier.fillMaxSize()) {
            // video ignores safe areas and fills edge-to-edge
            Box(Modifier.fillMaxSize()) { videoContent(page) }
            DoubleTapLikeLayer { /* register like */ }
            overlayContent(page)
        }
    }
}

@Composable
fun DoubleTapLikeLayer(onLike: () -> Unit) {
    val hearts = remember { mutableStateListOf<HeartBurst>() }
    val haptics = LocalHapticFeedback.current

    Box(
        Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                detectTapGestures(
                    onDoubleTap = { pos ->
                        val id = System.nanoTime()
                        hearts.add(HeartBurst(id, pos))
                        haptics.performHapticFeedback(HapticFeedbackType.LongPress) // heavy on first tap
                        onLike()
                    },
                )
            },
    ) {
        hearts.forEach { h -> key(h.id) { HeartBurstView(h) { hearts.remove(h) } } }
    }
}

data class HeartBurst(val id: Long, val pos: Offset)

@Composable
private fun HeartBurstView(burst: HeartBurst, onDone: () -> Unit) {
    val scale = remember { androidx.compose.animation.core.Animatable(0f) }
    val alpha = remember { androidx.compose.animation.core.Animatable(1f) }
    LaunchedEffect(burst.id) {
        // 0 → 1.4 → 1.0 over 400ms spring, then fade 1 → 0 over 200ms
        scale.animateTo(1.4f, spring(dampingRatio = 0.55f, stiffness = 500f))
        scale.animateTo(1.0f, spring(dampingRatio = 0.7f))
        alpha.animateTo(0f, tween(200))
        onDone()
    }
    Icon(
        Icons.Filled.Favorite, null,
        tint = TikTokColors.Rose,
        modifier = Modifier
            .offset { IntOffset((burst.pos.x - 60.dp.toPx()).roundToInt(), (burst.pos.y - 60.dp.toPx()).roundToInt()) }
            .size(120.dp)
            .scale(scale.value)
            .androidx.compose.ui.draw.alpha(alpha.value),
    )
}

Chromatic Loading Spinner

import androidx.compose.foundation.Canvas
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.geometry.Offset as GOffset

@Composable
fun TikTokLoadingSpinner(modifier: Modifier = Modifier) {
    val t = rememberInfiniteTransition(label = "spinner")
    val angle by t.animateFloat(0f, 360f, infiniteRepeatable(tween(1000, easing = LinearEasing)), label = "spin")
    Canvas(modifier.size(32.dp).rotate(angle)) {
        val sw = 3.dp.toPx()
        fun arc(color: androidx.compose.ui.graphics.Color, dx: Float) = drawArc(
            color = color, startAngle = 0f, sweepAngle = 270f, useCenter = false,
            topLeft = GOffset(dx, 0f), size = androidx.compose.ui.geometry.Size(size.width, size.height),
            style = Stroke(width = sw),
        )
        arc(TikTokColors.Cyan, -3.dp.toPx())
        arc(TikTokColors.Rose, 3.dp.toPx())
        arc(TikTokColors.TextPrimary, 0f)
    }
}

5. Navigation (Tab Bar)

TikTok’s iOS tab bar is transparent over the feed (only text-shadowed glyphs) and a .regularMaterial blur over rgba(1,1,1,0.92) on every other screen, with the centered chromatic Create button. Android has no first-class live blur — use a translucent Surface (Canvas at ~92% alpha) on non-feed screens and full transparency on the feed. Build a custom row (not NavigationBar) so you can swap materials and embed the Create button.

import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.HorizontalDivider

enum class TikTokTab { Home, Discover, Inbox, Profile }

@Composable
fun TikTokBottomBar(
    selected: TikTokTab,
    onSelect: (TikTokTab) -> Unit,
    onCreate: () -> Unit,
    isOnFeed: Boolean,
    modifier: Modifier = Modifier,
) {
    Box(modifier.fillMaxWidth()) {
        Column {
            if (!isOnFeed) HorizontalDivider(color = TikTokColors.TextPrimary.copy(alpha = 0.08f), thickness = 0.5.dp)
            Row(
                Modifier
                    .fillMaxWidth()
                    .height(48.dp)
                    .background(if (isOnFeed) Color.Transparent else TikTokColors.Canvas.copy(alpha = 0.92f))
                    .padding(horizontal = 8.dp),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Tab(TikTokTab.Home, Icons.Filled.Home, "Home", selected, isOnFeed, onSelect, Modifier.weight(1f))
                Tab(TikTokTab.Discover, Icons.Filled.Search, "Discover", selected, isOnFeed, onSelect, Modifier.weight(1f))
                Spacer(Modifier.width(56.dp)) // room for Create
                Tab(TikTokTab.Inbox, Icons.Filled.Inbox, "Inbox", selected, isOnFeed, onSelect, Modifier.weight(1f))
                Tab(TikTokTab.Profile, Icons.Filled.Person, "Profile", selected, isOnFeed, onSelect, Modifier.weight(1f))
            }
        }
        TikTokCreateButton(onClick = onCreate, modifier = Modifier.align(Alignment.Center))
    }
}

@Composable
private fun Tab(
    tab: TikTokTab, icon: ImageVector, label: String,
    selected: TikTokTab, isOnFeed: Boolean, onSelect: (TikTokTab) -> Unit, modifier: Modifier,
) {
    Column(
        modifier = modifier.clickable { onSelect(tab) },
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(3.dp),
    ) {
        val tint = if (selected == tab) TikTokColors.TextPrimary else TikTokColors.TextPrimary.copy(alpha = 0.5f)
        Icon(icon, label, tint = tint, modifier = Modifier.size(24.dp).then(if (isOnFeed) Modifier.tiktokIconShadow() else Modifier))
        Text(label, style = if (isOnFeed) TikTokText.Tab.onVideo() else TikTokText.Tab, color = tint)
    }
}

The top feed tabs (“Following” / “For You”) are just two text labels floating on the video with a 2dp animated underline — no container; render them in a Row above the feed with Modifier.windowInsetsPadding(WindowInsets.statusBars).

6. Motion & Haptics

Moment Compose recipe
Swipe up for next video VerticalPager, ~300ms spring(dampingRatio = 0.85f) paging
Swipe right/left (profile / For You) horizontal Pager with destination parallax (graphicsLayer)
Double-tap like heart Animatable 0 → 1.4 → 1.0 over 400ms spring, fade 1 → 0 tween(200); HapticFeedbackType.LongPress (heavy)
Heart icon tap Animatable 1 → 1.3 → 1.0 spring, rose fill swap, heavy haptic
Follow button pill recolors rose → translucent over ~200ms, scale 0.95 → 1.0; success haptic on follow
Spinning music disc rememberInfiniteTransition 360° / 6s LinearEasing, pauses with video
Comment sheet ModalBottomSheet slide-up ~350ms spring(dampingRatio = 0.85f)
Create tap tab bar dims ~0.8s, camera UI fades in tween(400)
// Heart single-tap — 1.0 → 1.3 → 1.0 bounce + heavy haptic + rose swap
@Composable
fun TikTokHeart(isLiked: Boolean, onToggle: () -> Unit) {
    val scale = remember { androidx.compose.animation.core.Animatable(1f) }
    val haptics = LocalHapticFeedback.current
    LaunchedEffect(isLiked) {
        if (isLiked) {
            haptics.performHapticFeedback(HapticFeedbackType.LongPress) // heavy
            scale.animateTo(1.3f, spring(dampingRatio = 0.5f, stiffness = 600f))
            scale.animateTo(1.0f, spring(dampingRatio = 0.7f))
        }
    }
    Icon(
        if (isLiked) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
        contentDescription = if (isLiked) "Unlike" else "Like",
        tint = if (isLiked) TikTokColors.Rose else TikTokColors.TextPrimary,
        modifier = Modifier.size(30.dp).scale(scale.value).tiktokIconShadow().clickable(onClick = onToggle),
    )
}

Haptics: LocalHapticFeedback LongPress approximates iOS heavy/success; TextHandleMove approximates soft. For a true graded heavy cue on double-tap-like, use LocalView.current.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) or a Vibrator VibrationEffect.createOneShot(18, 255).

7. Icons

TikTok uses custom glyphs (the chromatic wordmark especially). The closest first-party set is androidx.compose.material:material-icons-extended; ship the chromatic wordmark and any brand-exact glyphs as vector drawables loaded via ImageVector.vectorResource(R.drawable.…).

Component SF Symbol (iOS) Material Icon (Compose)
Heart (idle / active) heart / heart.fill Icons.Filled.FavoriteBorder / Icons.Filled.Favorite
Comment ellipsis.bubble.fill Icons.Filled.Comment
Bookmark bookmark.fill Icons.Filled.Bookmark
Share arrowshape.turn.up.right.fill Icons.Filled.Reply
Music note music.note Icons.Filled.MusicNote
Create plus plus Icons.Filled.Add
Home tab house.fill Icons.Filled.Home
Discover tab magnifyingglass Icons.Filled.Search
Inbox tab tray.fill Icons.Filled.Inbox
Profile tab person.fill Icons.Filled.Person
Back chevron.backward Icons.Filled.ArrowBackIosNew
More ellipsis Icons.Filled.MoreHoriz
Send paperplane.fill Icons.Filled.Send
Live dot.radiowaves.left.and.right Icons.Filled.SensorsOff (or custom live drawable)
Wordmark “d” custom chromatic glyph vector drawable via ChromaticAberration

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; VerticalPager, ExoPlayer, and the chromatic motion are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge() in the Activity. The video content draws under the system bars (the 9:16 frame fills edge-to-edge, including under the camera cutout); only the UI overlays respect insets via Modifier.windowInsetsPadding(WindowInsets.safeDrawing). Force a light status bar (white over video) since there is no light mode.
  • Font scaling: sp honors the user’s font scale — sheet content (comments, inbox, settings) scales fully. Pin the feed overlays (caption, username, action counts) and the 10sp tab labels via CompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)) — the 9:16 layout is tight and scaling breaks it.
  • TalkBack: label the heart “Like video by @username, 24.5K likes” and the Follow button “Follow @username” via Modifier.semantics. Mark the spinning music disc decorative unless it opens the audio page (then expose it as a button). The double-tap layer should also expose a single-tap accessible “Like” action.
  • Touch targets: Material guidance is 48.dp. The right-rail glyphs are 30dp on a 48dp defaultMinSize target; the Create button is 44×30dp with a ~56dp effective hit area — verify with Modifier.minimumInteractiveComponentSize().
  • Contrast: white text on video is reinforced by the rgba(0,0,0,0.4) shadow folded into TextStyle.onVideo() — validate on bright-frame samples; never render white text on a gray surface without it.
  • Reduce motion: detect Settings.Global.ANIMATOR_DURATION_SCALE == 0f (or a stored pref) — freeze the spinning music disc and skip the double-tap burst spring (fall back to an instant heart fade), but keep the heavy haptic.
  • Orientation: lock the feed to portrait (android:screenOrientation="portrait" on the feed Activity, or requestedOrientation) — the 9:16 video is the entire thesis. The video-player modal from the profile grid may rotate to landscape.
  • Dynamic color: do not enable Material You dynamicColorScheme() — TikTok’s brand is the fixed rose/cyan-on-#010101 chromatic identity; a wallpaper-derived palette would destroy the aberration treatment and the verb/accent split.
All mobile designs →

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