coderef

TIDAL

TIDAL's iOS app is the most austere player in music streaming, and that austerity is the brand. The canvas is pure black (#000000) — not the softened "not-quite-black" of competitors, but true OLED black that makes album art appear to…
DownloadRaw
TIDAL

Build with the TIDAL look.

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

Palette

At a glance

Theme
dark
Typefaces
Space Grotesk
Sections
8
Length
3,379 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · TIDAL

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

TIDAL (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports TIDAL’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the signature quality badge, 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 (TIDAL’s pure-black void, white actions, fidelity-only cyan, square art, radical flatness) while making everything idiomatic Android — NavigationBar instead of a UITabBar, tonal-step surfaces instead of shadows, 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/TidalColors.kt
import androidx.compose.ui.graphics.Color

object TidalColors {
    // Canvas & Surfaces (tonal steps — TIDAL uses these instead of shadows)
    val Canvas   = Color(0xFF000000)
    val Surface1 = Color(0xFF0A0A0A)
    val Surface2 = Color(0xFF1A1A1A)
    val Divider  = Color(0xFF262626)

    // Text
    val TextPrimary   = Color(0xFFFFFFFF)
    val TextSecondary = Color(0xFF9A9A9A)
    val TextTertiary  = Color(0xFF5C5C5C)

    // Brand
    val White        = Color(0xFFFFFFFF) // action color
    val Cyan         = Color(0xFF00FFFF) // quality-tier badges ONLY
    val CyanPressed  = Color(0xFF00CCCC)
    val ErrorRed     = Color(0xFFFF453A)
}

Wire it into a Material 3 darkColorScheme. TIDAL is dark-only; do not provide a light scheme.

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

private val TidalScheme = darkColorScheme(
    primary        = TidalColors.White,       // white is the action color
    onPrimary      = TidalColors.Canvas,      // black on white
    background      = TidalColors.Canvas,
    onBackground    = TidalColors.TextPrimary,
    surface         = TidalColors.Surface1,
    onSurface       = TidalColors.TextPrimary,
    surfaceVariant  = TidalColors.Surface2,
    outline         = TidalColors.Divider,
    tertiary        = TidalColors.Cyan,        // reserved: quality badges only
    error           = TidalColors.ErrorRed,
)

@Composable
fun TidalTheme(content: @Composable () -> Unit) =
    MaterialTheme(colorScheme = TidalScheme, typography = TidalTypography, content = content)

2. Typography

TIDAL’s brand sans is proprietary. Drop the TTFs in res/font/ (lowercase, snake_case), or use Space Grotesk as the closest free geometric grotesque; final fallback is the system font (Roboto).

// ui/theme/TidalType.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 SpaceGrotesk = FontFamily(
    Font(R.font.space_grotesk_regular,  FontWeight.Normal),   // 400
    Font(R.font.space_grotesk_semibold, FontWeight.SemiBold), // 600
    Font(R.font.space_grotesk_bold,     FontWeight.Bold),     // 700
)

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object TidalText {
    val TitleLarge  = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Bold,     fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.4).sp)
    val NowPlaying  = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Bold,     fontSize = 24.sp, lineHeight = 29.sp, letterSpacing = (-0.3).sp)
    val Section     = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Bold,     fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
    val AlbumTitle  = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Bold,     fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp)
    val TrackTitle  = TextStyle(SpaceGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 21.sp)
    val CardTitle   = TextStyle(SpaceGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
    val Subtitle    = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Normal,   fontSize = 14.sp, lineHeight = 18.sp)
    val Body        = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Normal,   fontSize = 15.sp, lineHeight = 22.sp)
    val Meta        = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Normal,   fontSize = 12.sp, lineHeight = 16.sp)
    val Badge       = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Bold,     fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 1.0.sp)
    val LabelUpper  = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Bold,     fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.8.sp)
    val Button      = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Bold,     fontSize = 15.sp, lineHeight = 18.sp, letterSpacing = 0.3.sp)
    val Tab         = TextStyle(SpaceGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.3.sp)
    val Timestamp   = TextStyle(SpaceGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 13.sp)
}

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

3. Signature Components

Quality-Tier Badge

import androidx.compose.foundation.border
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp

enum class TidalQuality(val label: String) {
    MASTER("MASTER"), HIFI("HIFI"), MAX("MAX"), ATMOS("DOLBY ATMOS")
}

@Composable
fun QualityBadge(quality: TidalQuality, modifier: Modifier = Modifier) {
    Text(
        text = quality.label,
        style = TidalText.Badge,
        color = TidalColors.Cyan,
        modifier = modifier
            .border(1.dp, TidalColors.Cyan, RoundedCornerShape(4.dp))
            .padding(horizontal = 8.dp, vertical = 4.dp)
            .semantics { contentDescription = "${quality.label} quality" },
    )
}

Primary Play Button (flat — no shadow)

@Composable
fun TidalPlayButton(
    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.75f), label = "play")
    val haptics = LocalHapticFeedback.current

    Box(
        modifier
            .size(size)
            .scale(scale)
            .clip(CircleShape)
            .background(if (pressed) Color(0xFFE5E5E5) else TidalColors.White), // no shadow — flat
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
            contentDescription = if (isPlaying) "Pause" else "Play",
            tint = TidalColors.Canvas, // black on white
            modifier = Modifier.size(size * 0.4f),
        )
        // intentionally no .clickable indication ripple beyond default; attach onClick:
    }.also {
        // attach click + haptic
    }
}

Use this click-wired variant in practice:

@Composable
fun TidalPlayButton(isPlaying: Boolean, onClick: () -> Unit, 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.75f), label = "play")
    val haptics = LocalHapticFeedback.current
    Box(
        Modifier
            .size(size).scale(scale).clip(CircleShape)
            .background(if (pressed) Color(0xFFE5E5E5) else TidalColors.White)
            .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 = TidalColors.Canvas,
            modifier = Modifier.size(size * 0.4f),
        )
    }
}

Primary / Outline Pill

enum class PillStyle { Filled, Outline }

@Composable
fun TidalPill(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
    Box(
        modifier
            .scale(scale)
            .clip(CircleShape) // full pill
            .then(
                if (filled) Modifier.background(if (pressed) Color(0xFFE5E5E5) else TidalColors.White)
                else Modifier.border(1.dp, if (pressed) TidalColors.White else TidalColors.TextSecondary, CircleShape)
            )
            .clickable(interaction, indication = null, onClick = onClick)
            .padding(horizontal = if (filled) 32.dp else 24.dp, vertical = 11.dp),
        contentAlignment = Alignment.Center,
    ) {
        Text(
            text,
            style = if (filled) TidalText.Button else TidalText.Subtitle.copy(fontWeight = FontWeight.SemiBold),
            color = if (filled) TidalColors.Canvas else TidalColors.TextPrimary,
        )
    }
}

Track Row (with quality badge + equalizer)

@Composable
fun TrackRow(
    title: String,
    artist: String,
    artworkUrl: String,
    duration: String,
    quality: TidalQuality?,
    isPlaying: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Row(
        modifier
            .fillMaxWidth()
            .height(60.dp)
            .background(if (pressed) TidalColors.Surface2 else TidalColors.Canvas)
            .clickable(interaction, indication = null, onClick = onClick)
            .padding(horizontal = 16.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(10.dp),
    ) {
        Box {
            AsyncImage(
                model = artworkUrl,
                contentDescription = null,
                modifier = Modifier.size(44.dp), // square — no clip/round
                contentScale = ContentScale.Crop,
            )
            if (isPlaying) Equalizer(Modifier.align(Alignment.CenterStart).padding(start = 4.dp))
        }
        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
            Text(title, style = TidalText.TrackTitle, color = TidalColors.TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
            Text(artist, style = TidalText.Subtitle, color = TidalColors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
        }
        quality?.let { QualityBadge(it) }
        Text(duration, style = TidalText.Timestamp, color = TidalColors.TextSecondary)
        Icon(Icons.Filled.MoreHoriz, "More", tint = TidalColors.TextSecondary, modifier = Modifier.size(20.dp))
    }
}

@Composable
fun Equalizer(modifier: Modifier = Modifier) {
    val t = rememberInfiniteTransition(label = "eq")
    Row(modifier, horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.Bottom) {
        repeat(3) { i ->
            val h by t.animateFloat(0.3f, 1f, infiniteRepeatable(tween(360 + i * 120), RepeatMode.Reverse), label = "bar$i")
            Box(Modifier.width(3.dp).height((16 * h).dp).background(TidalColors.White))
        }
    }
}

4. Quality Tier Mapping

fun badgeFor(format: String): TidalQuality? = when (format) {
    "flac-24"     -> TidalQuality.MASTER
    "flac-16"     -> TidalQuality.HIFI
    "flac-24-max" -> TidalQuality.MAX
    "atmos"       -> TidalQuality.ATMOS
    else          -> null // no badge for lossy
}

5. Bottom Navigation (Tab Bar)

Use Material 3 NavigationBar. TIDAL’s iOS tab bar is .regularMaterial blur over near-black; Android has no first-class live blur, so use a 96%-opaque black surface. Active tint is white — cyan is fidelity-only.

@Composable
fun TidalBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(
        containerColor = TidalColors.Canvas.copy(alpha = 0.96f),
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            "Home"          to Icons.Filled.Home,
            "Videos"        to Icons.Filled.Movie,
            "Search"        to Icons.Filled.Search,
            "My Collection" to Icons.Filled.LibraryMusic,
        )
        items.forEachIndexed { i, (label, icon) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = { onSelect(i) },
                icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp)) },
                label = { Text(label, style = TidalText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = TidalColors.White,
                    selectedTextColor   = TidalColors.White,
                    unselectedIconColor = TidalColors.TextSecondary,
                    unselectedTextColor = TidalColors.TextSecondary,
                    indicatorColor      = Color.Transparent, // TIDAL has no Material pill
                ),
            )
        }
    }
}

The persistent Now Playing mini-bar sits directly above this bar: a 52.dp Surface(color = TidalColors.Surface2) row with a 1.dp TidalColors.White progress hairline pinned to its top edge — render it in the Scaffold bottomBar slot stacked above TidalBottomBar.

6. Motion

Moment Compose recipe
Screen / now-playing transition fadeIn() / fadeOut() crossfade only — no slide, no spring
Play/pause tap animateFloatAsState 1 → 0.93 with spring(dampingRatio = 0.75f), HapticFeedbackType.LongPress
Scrubber drag Animate track thickness 1.dp → 3.dp; HapticFeedbackType.TextHandleMove ticks
Equalizer (now-playing row) rememberInfiniteTransition driving 3 white bars’ height, frozen when paused
Favorite tap Animatable keyframes 1 → 1.12 → 1 over 260ms; HapticFeedbackType.LongPress
Mini-bar → full player Crossfade on the screen + SharedTransitionLayout artwork that fades (not springs)
// Crossfade navigation (TIDAL's editorial restraint — no slide)
Crossfade(targetState = route, animationSpec = tween(250), label = "nav") { r ->
    when (r) {
        Route.Home -> HomeScreen()
        Route.NowPlaying -> NowPlayingScreen()
        else -> {}
    }
}

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

7. Icons

The closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export TIDAL’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
Favorite heart / heart.fill Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite
Add to collection plus.circle Icons.Outlined.AddCircle
Credits info.circle Icons.Outlined.Info
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
Videos (tab) play.rectangle.fill Icons.Filled.Movie
Collection (tab) square.stack.fill Icons.Filled.LibraryMusic
Cast airplayaudio Icons.Filled.Cast

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; modern motion is comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); the pure-black canvas wants light-content system bars. Apply Scaffold insets so the mini-bar clears gesture nav.
  • Font scaling: sp honors the user’s font scale — keep it on titles, artists, body. Pin quality badges, timestamps, and tab labels (exact geometry) by deriving size from dp or CompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)).
  • TalkBack: include the quality tier in the row semantics (“…, Master quality, 3:08”); the QualityBadge already sets contentDescription.
  • Touch targets: Material guidance is 48.dp minimum. The 64.dp play button clears it; give the 20.dp ellipsis and the 10.dp quality badge a 48.dp hit area via padding.
  • Contrast: #00FFFF on #000000 is AAA — safe for the 10sp badge. #9A9A9A on #000000 passes AA at 14sp+; validate the 11sp meta/timestamp and lighten toward #A8A8A8 if targeting strict compliance.
  • Dynamic color: do not enable Material You dynamicDarkColorScheme() — TIDAL’s brand requires the fixed pure-black canvas and the white/cyan system regardless of wallpaper. There is no light theme.
All mobile designs →

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