coderef

SoundCloud

SoundCloud's iOS app is built around one idea that no other audio app has: the waveform is the interface. Every track — a bedroom demo, a DJ set, a podcast — renders as a horizontal amplitude graph in SoundCloud Orange (#FF5500), and that…
DownloadRaw
SoundCloud

Build with the SoundCloud look.

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

Palette

At a glance

Theme
light
Typefaces
Interstate
Sections
8
Length
3,453 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · SoundCloud

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

SoundCloud (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports SoundCloud’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the signature waveform scrubber, 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 (SoundCloud’s light canvas, single-orange accent, the commentable waveform) while making everything idiomatic Android — NavigationBar instead of a UITabBar, a Canvas-drawn waveform instead of a SwiftUI shape, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for images.

1. Color Tokens

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

object SoundCloudColors {
    // Light
    val Canvas  = Color(0xFFFFFFFF)
    val Surface = Color(0xFFF2F2F2)
    val Divider = Color(0xFFE5E5E5)

    // Dark
    val CanvasDark  = Color(0xFF1A1A1A)
    val SurfaceDark = Color(0xFF262626)
    val DividerDark = Color(0xFF333333)

    // Text
    val TextPrimary     = Color(0xFF333333)
    val TextPrimaryDark = Color(0xFFFFFFFF)
    val TextSecondary   = Color(0xFF999999)
    val TextTertiary    = Color(0xFFBFBFBF)

    // Brand
    val Orange        = Color(0xFFFF5500)
    val OrangeLight   = Color(0xFFFF7700)
    val OrangePressed = Color(0xFFE64A00)
    val ErrorRed      = Color(0xFFE5484D)
}

SoundCloud ships both light and dark. Provide both Material 3 schemes; the orange is constant across them.

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

private val SCLight = lightColorScheme(
    primary        = SoundCloudColors.Orange,
    onPrimary      = Color.White,
    background      = SoundCloudColors.Canvas,
    onBackground    = SoundCloudColors.TextPrimary,
    surface         = SoundCloudColors.Surface,
    onSurface       = SoundCloudColors.TextPrimary,
    surfaceVariant  = SoundCloudColors.Surface,
    outline         = SoundCloudColors.Divider,
    error           = SoundCloudColors.ErrorRed,
)

private val SCDark = darkColorScheme(
    primary        = SoundCloudColors.Orange,
    onPrimary      = Color.White,
    background      = SoundCloudColors.CanvasDark,
    onBackground    = SoundCloudColors.TextPrimaryDark,
    surface         = SoundCloudColors.SurfaceDark,
    onSurface       = SoundCloudColors.TextPrimaryDark,
    surfaceVariant  = SoundCloudColors.SurfaceDark,
    outline         = SoundCloudColors.DividerDark,
    error           = SoundCloudColors.ErrorRed,
)

@Composable
fun SoundCloudTheme(
    dark: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) = MaterialTheme(
    colorScheme = if (dark) SCDark else SCLight,
    typography  = SoundCloudTypography,
    content     = content,
)

2. Typography

Interstate is licensed. Drop the TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to the bundled Inter, then the system font (Roboto).

// ui/theme/SoundCloudType.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 Interstate = FontFamily(
    Font(R.font.interstate_regular, FontWeight.Normal), // 400
    Font(R.font.interstate_medium,  FontWeight.Medium), // 500
    Font(R.font.interstate_bold,    FontWeight.Bold),   // 700
)

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object SoundCloudText {
    val TitleLarge  = TextStyle(Interstate, fontWeight = FontWeight.Bold,   fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.3).sp)
    val NowPlaying  = TextStyle(Interstate, fontWeight = FontWeight.Bold,   fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.2).sp)
    val Section     = TextStyle(Interstate, fontWeight = FontWeight.Bold,   fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.2).sp)
    val ProfileName = TextStyle(Interstate, fontWeight = FontWeight.Bold,   fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.1).sp)
    val TrackTitle  = TextStyle(Interstate, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 21.sp)
    val CardTitle   = TextStyle(Interstate, fontWeight = FontWeight.Medium, fontSize = 15.sp, lineHeight = 20.sp)
    val Subtitle    = TextStyle(Interstate, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 18.sp)
    val Body        = TextStyle(Interstate, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
    val Comment     = TextStyle(Interstate, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
    val Meta        = TextStyle(Interstate, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
    val LabelUpper  = TextStyle(Interstate, fontWeight = FontWeight.Bold,   fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.6.sp)
    val Button      = TextStyle(Interstate, fontWeight = FontWeight.Bold,   fontSize = 15.sp, lineHeight = 18.sp, letterSpacing = 0.2.sp)
    val Tab         = TextStyle(Interstate, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.2.sp)
    val Timestamp   = TextStyle(Interstate, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 13.sp)
}

// Map onto Material 3 slots so stock components inherit the brand
val SoundCloudTypography = Typography(
    headlineLarge = SoundCloudText.TitleLarge,
    headlineSmall = SoundCloudText.Section,
    titleMedium   = SoundCloudText.TrackTitle,
    bodyMedium    = SoundCloudText.Body,
    labelSmall    = SoundCloudText.Tab,
)

3. Signature Components

Commentable Waveform Scrubber

The iOS waveform is a custom-drawn amplitude graph. On Android, draw it on a Compose Canvas, detect drag for seek, and pin comment avatars along the baseline.

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.DrawScope
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 kotlin.math.roundToInt

data class WaveComment(val id: String, val position: Float, val avatarUrl: String, val text: String)

@Composable
fun WaveformScrubber(
    samples: List<Float>,           // 0..1 normalized amplitudes
    progress: Float,                // 0..1 playback position
    comments: List<WaveComment> = emptyList(),
    height: Dp = 64.dp,
    onSeek: (Float) -> Unit = {},
    modifier: Modifier = Modifier,
) {
    val haptics = LocalHapticFeedback.current
    var width by remember { mutableStateOf(1f) }

    Box(modifier.fillMaxWidth().height(height + 16.dp)) {
        Canvas(
            Modifier
                .fillMaxWidth()
                .height(height)
                .pointerInput(Unit) {
                    detectTapGestures { onSeek((it.x / size.width).coerceIn(0f, 1f)) }
                }
                .pointerInput(Unit) {
                    detectDragGestures { change, _ ->
                        val p = (change.position.x / size.width).coerceIn(0f, 1f)
                        onSeek(p)
                        haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
                    }
                },
        ) {
            width = size.width
            val barW = 1.5.dp.toPx()
            val gap = 1.dp.toPx()
            val count = (size.width / (barW + gap)).toInt().coerceAtLeast(1)
            for (i in 0 until count) {
                val amp = if (samples.isEmpty()) 0.3f else samples[(i * samples.size / count)]
                val played = i.toFloat() / count <= progress
                val h = (amp * (size.height - 8.dp.toPx())).coerceAtLeast(3.dp.toPx())
                drawBar(
                    x = i * (barW + gap),
                    height = h,
                    width = barW,
                    canvasHeight = size.height,
                    color = if (played) SoundCloudColors.Orange
                            else SoundCloudColors.OrangeLight.copy(alpha = 0.45f),
                )
            }
            // playhead
            val px = progress * size.width
            drawLine(
                color = SoundCloudColors.TextPrimary,
                start = Offset(px, 0f),
                end = Offset(px, size.height),
                strokeWidth = 1.dp.toPx(),
            )
        }

        // inline comment avatars on the baseline
        comments.forEach { c ->
            AsyncImage(
                model = c.avatarUrl,
                contentDescription = "Comment by ${c.id}",
                modifier = Modifier
                    .size(20.dp)
                    .offset { IntOffset((c.position * width - 10.dp.toPx()).roundToInt(), (height - 4.dp).roundToPx()) }
                    .clip(CircleShape)
                    .border(1.5.dp, SoundCloudColors.Canvas, CircleShape),
            )
        }
    }
}

private fun DrawScope.drawBar(x: Float, height: Float, width: Float, canvasHeight: Float, color: Color) {
    drawRoundRect(
        color = color,
        topLeft = Offset(x, (canvasHeight - height) / 2f),
        size = Size(width, height),
        cornerRadius = androidx.compose.ui.geometry.CornerRadius(1f, 1f),
    )
}

Primary Play FAB

@Composable
fun SCPlayButton(
    isPlaying: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    size: Dp = 64.dp,
) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 0.93f else 1f, spring(dampingRatio = 0.7f), label = "fab")
    val haptics = LocalHapticFeedback.current

    Box(
        modifier
            .size(size)
            .scale(scale)
            .shadow(20.dp, CircleShape, spotColor = SoundCloudColors.Orange.copy(alpha = 0.32f))
            .clip(CircleShape)
            .background(if (pressed) SoundCloudColors.OrangePressed else SoundCloudColors.Orange)
            .clickable(interaction, indication = null) {
                haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS medium impact
                onClick()
            },
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
            contentDescription = if (isPlaying) "Pause" else "Play",
            tint = Color.White,
            modifier = Modifier.size(size * 0.4f),
        )
    }
}

Primary / Outline Pill (Follow / Following)

enum class PillStyle { Filled, Outline }

@Composable
fun SCPill(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    style: PillStyle = PillStyle.Filled,
) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 0.97f else 1f, label = "pill")
    val filled = style == PillStyle.Filled
    val shape = RoundedCornerShape(4.dp) // SoundCloud's slight rounding, not a full pill

    Box(
        modifier
            .scale(scale)
            .clip(shape)
            .then(
                if (filled) Modifier.background(if (pressed) SoundCloudColors.OrangePressed else SoundCloudColors.Orange)
                else Modifier.border(1.dp, if (pressed) SoundCloudColors.Orange else SoundCloudColors.TextSecondary, shape)
            )
            .clickable(interaction, indication = null, onClick = onClick)
            .padding(horizontal = if (filled) 24.dp else 20.dp, vertical = 9.dp),
        contentAlignment = Alignment.Center,
    ) {
        Text(
            text,
            style = if (filled) SoundCloudText.Button else SoundCloudText.Subtitle,
            color = if (filled) Color.White else MaterialTheme.colorScheme.onBackground,
        )
    }
}

Track Row (with mini-waveform)

@Composable
fun TrackRow(
    title: String,
    uploader: String,
    artworkUrl: String,
    samples: List<Float>,
    progress: Float,
    isPlaying: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()

    Row(
        modifier
            .fillMaxWidth()
            .height(72.dp)
            .background(if (pressed) MaterialTheme.colorScheme.surface else Color.Transparent)
            .clickable(interaction, indication = null, onClick = onClick)
            .padding(horizontal = 16.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        AsyncImage(
            model = artworkUrl,
            contentDescription = null,
            modifier = Modifier.size(56.dp).clip(RoundedCornerShape(4.dp)),
            contentScale = ContentScale.Crop,
        )
        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
            Text(uploader, style = SoundCloudText.Subtitle, color = SoundCloudColors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
            Text(
                title,
                style = SoundCloudText.TrackTitle,
                color = if (isPlaying) SoundCloudColors.Orange else MaterialTheme.colorScheme.onBackground,
                maxLines = 1, overflow = TextOverflow.Ellipsis,
            )
        }
        Box(Modifier.width(110.dp).height(28.dp)) {
            WaveformScrubber(samples = samples, progress = progress, height = 28.dp)
        }
        Icon(Icons.Filled.MoreHoriz, "More", tint = SoundCloudColors.TextSecondary, modifier = Modifier.size(20.dp))
    }
}

4. Inline Comment Reveal

Drive reveal from the player position so a comment surfaces exactly as the orange sweep reaches its avatar:

@Composable
fun rememberRevealedComment(progress: Float, comments: List<WaveComment>): WaveComment? {
    var revealed by remember { mutableStateOf<WaveComment?>(null) }
    LaunchedEffect(progress) {
        comments.firstOrNull { kotlin.math.abs(it.position - progress) < 0.004f }?.let {
            revealed = it
            kotlinx.coroutines.delay(3000)
            revealed = null
        }
    }
    return revealed
}

5. Bottom Navigation (Tab Bar)

Use Material 3 NavigationBar. SoundCloud’s iOS tab bar is .regularMaterial blur; Android has no first-class live blur, so use a 94%-opaque canvas surface. Active tint is SoundCloud Orange.

@Composable
fun SCBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(
        containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.94f),
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            "Home"    to Icons.Filled.Home,
            "Search"  to Icons.Filled.Search,
            "Library" to Icons.Filled.LibraryMusic,
            "Upload"  to Icons.Filled.AddCircle,
            "You"     to Icons.Filled.AccountCircle,
        )
        items.forEachIndexed { i, (label, icon) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = { onSelect(i) },
                icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(if (label == "Upload") 28.dp else 24.dp)) },
                label = { Text(label, style = SoundCloudText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = SoundCloudColors.Orange,
                    selectedTextColor   = SoundCloudColors.Orange,
                    unselectedIconColor = SoundCloudColors.TextSecondary,
                    unselectedTextColor = SoundCloudColors.TextSecondary,
                    indicatorColor      = Color.Transparent, // SoundCloud has no Material pill
                ),
            )
        }
    }
}

The persistent Now Playing mini-bar sits directly above this bar: a 52.dp Surface row with a 2.dp SoundCloudColors.Orange line pinned to its top edge as the always-visible progress cue — render it in the Scaffold bottomBar slot stacked above SCBottomBar.

6. Motion

Moment Compose recipe
Waveform progress sweep Advance progress linearly from the player clock (position / duration); recompose the Canvas — no easing
Play/pause tap animateFloatAsState 1 → 0.93 with spring(dampingRatio = 0.7f), HapticFeedbackType.LongPress
Inline comment reveal AnimatedVisibility + slideInVertically + fadeIn over 200ms when the playhead passes the avatar
Like tap Animatable keyframes 1 → 1.2 → 1 over 280ms; HapticFeedbackType.LongPress
Scrub HapticFeedbackType.TextHandleMove on each drag delta crossing ~5%
Mini-bar → full player SharedTransitionLayout + Modifier.sharedElement() on the artwork (Compose 1.7+); fallback AnimatedVisibility + slideInVertically
// Like bounce
@Composable
fun LikeButton(liked: Boolean, onToggle: () -> Unit) {
    val scale = remember { Animatable(1f) }
    val haptics = LocalHapticFeedback.current
    val scope = rememberCoroutineScope()
    Icon(
        if (liked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
        contentDescription = "Like",
        tint = if (liked) SoundCloudColors.Orange else SoundCloudColors.TextSecondary,
        modifier = Modifier
            .size(20.dp)
            .scale(scale.value)
            .clickable {
                scope.launch {
                    scale.animateTo(1.2f, tween(140)); scale.animateTo(1f, tween(140))
                }
                haptics.performHapticFeedback(HapticFeedbackType.LongPress)
                onToggle()
            },
    )
}

Haptics: prefer LocalHapticFeedback. For scrub ticks, LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) (API 30+) approximates iOS’s selection feedback.

7. Icons

The closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export SoundCloud’s glyphs as vector drawables and load via ImageVector.vectorResource(R.drawable.…).

Purpose SF Symbol (iOS) Material Icon (Compose)
Play play.fill Icons.Filled.PlayArrow
Pause pause.fill Icons.Filled.Pause
Next / Previous forward.end.fill / backward.end.fill Icons.Filled.SkipNext / SkipPrevious
Shuffle shuffle Icons.Filled.Shuffle
Repeat repeat Icons.Filled.Repeat
Like heart / heart.fill Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite
Repost arrow.2.squarepath Icons.Filled.Repeat
Comment bubble.right Icons.Outlined.ChatBubbleOutline
Share square.and.arrow.up Icons.Filled.Share
More ellipsis Icons.Filled.MoreHoriz
Search magnifyingglass Icons.Filled.Search
Home (tab) house.fill Icons.Filled.Home
Library (tab) play.square.stack Icons.Filled.LibraryMusic
Upload (tab) arrow.up.circle.fill Icons.Filled.AddCircle
Profile (tab) person.crop.circle Icons.Filled.AccountCircle

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; the Canvas waveform + modern motion are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge() in the Activity; apply Modifier.windowInsetsPadding(WindowInsets.systemBars) or Scaffold insets so the mini-bar clears gesture nav.
  • Font scaling: sp honors the user’s font scale automatically — keep it on titles, uploader names, body, comments. Pin layout-sensitive text (waveform timestamps, tab labels) by deriving size from dp or wrapping in CompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)).
  • TalkBack: give the waveform Modifier.semantics { progressBarRangeInfo = ProgressBarRangeInfo(progress, 0f..1f) } and a custom seek action; announce inline comments via LiveRegionMode.Polite.
  • Touch targets: Material guidance is 48.dp minimum. The 64.dp FAB clears it; ensure the 20.dp action icons get Modifier.size(48.dp) hit area via padding.
  • Contrast: #999999 on #FFFFFF passes WCAG AA at 14sp+; validate the 11sp timestamps and darken toward #808080 if targeting strict compliance.
  • Dynamic color: do not enable Material You dynamicColorScheme() — SoundCloud’s brand requires the fixed orange and the #FFFFFF/#1A1A1A canvases 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