coderef

Perplexity

Perplexity's iOS app is the answer engine made tactile. Where ChatGPT and Claude are chat surfaces optimized for conversation, Perplexity is a search engine optimized for one shape of interaction: ask a question, get a cited answer.
DownloadRaw
Perplexity

Build with the Perplexity look.

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

Palette

At a glance

Theme
dark
Typefaces
Inter, FK Grotesk, JetBrains Mono
Sections
8
Length
5,151 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Perplexity

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

Perplexity (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Perplexity’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the triple-circle brand mark, inline citation chips, 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 (Perplexity’s near-black research-notebook canvas, the single cyan-teal accent, inline citations + source cards, the triple-circle mark) while making everything idiomatic Android — NavigationBar instead of a UITabBar, a Path-drawn mark instead of a SwiftUI Shape, LazyRow source cards, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for source-card favicons and Discover hero images. No color extraction — the accent is a fixed teal scale, so Palette is not needed. Perplexity is dark-first; a light scheme is provided.

1. Color Tokens

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

object PerplexityColors {
    // Canvas (dark-first)
    val Canvas   = Color(0xFF0A0A0A) // near-black — every screen by default
    val Surface1 = Color(0xFF171717) // input field, source card, modal
    val Surface2 = Color(0xFF1F1F1F) // chips, pressed
    val Surface3 = Color(0xFF2A2A2A) // sheets, sticky headers
    val Divider  = Color(0xFF2A2A2A)

    // Text (on dark)
    val TextPrimary   = Color(0xFFFAFAFA) // off-white — NOT pure white
    val TextSecondary = Color(0xFFA1A1A1)
    val TextTertiary  = Color(0xFF6E6E6E)
    val TextMuted     = Color(0xFF4A4A4A)

    // Perplexity Teal (the single signature accent)
    val Teal       = Color(0xFF20B8CD)
    val TealBright = Color(0xFF3DD6EC) // streaming cursor, hover
    val TealDeep   = Color(0xFF1591A3) // pressed, light-mode teal
    val TealSoft   = Color(0xFF0F3A42) // Pro Steps fill, focused chip bg

    // Code & syntax
    val CodeBg     = Color(0xFF0E0E0E)
    val CodeFg     = Color(0xFFE5E5E5)
    val SyntaxStr  = Color(0xFFA8E063) // lime
    val SyntaxNum  = Color(0xFFF2A65A) // soft orange
    val SyntaxFunc = Color(0xFFB988F2) // soft purple

    // Semantic
    val Success = Color(0xFF22C55E)
    val Warning = Color(0xFFF59E0B)
    val Error   = Color(0xFFEF4444)
    val ProGold = Color(0xFFE0B341)

    // Light mode (supported, but Perplexity defaults dark)
    val LightCanvas    = Color(0xFFFFFFFF)
    val LightSurface1  = Color(0xFFF7F7F7)
    val LightSurface2  = Color(0xFFEFEFEF)
    val LightDivider   = Color(0xFFE5E5E5)
    val LightTextPri   = Color(0xFF111111)
    val LightTextSec   = Color(0xFF555555)
    val TealLight      = Color(0xFF1591A3) // darker teal for WCAG AA on white
}

Wire it into both schemes. Perplexity is dark-first; the light scheme preserves all teal accents but darkens the teal to #1591A3 for WCAG on white.

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

private val PerplexityDark = darkColorScheme(
    primary        = PerplexityColors.Teal,
    onPrimary      = PerplexityColors.Canvas,  // intentional: dark text on teal (WCAG)
    background     = PerplexityColors.Canvas,
    onBackground   = PerplexityColors.TextPrimary,
    surface        = PerplexityColors.Surface1,
    onSurface      = PerplexityColors.TextPrimary,
    surfaceVariant = PerplexityColors.Surface2,
    outline        = PerplexityColors.Divider,
    error          = PerplexityColors.Error,
)

private val PerplexityLight = lightColorScheme(
    primary        = PerplexityColors.TealLight,
    onPrimary      = PerplexityColors.LightCanvas,
    background     = PerplexityColors.LightCanvas,
    onBackground   = PerplexityColors.LightTextPri,
    surface        = PerplexityColors.LightSurface1,
    onSurface      = PerplexityColors.LightTextPri,
    surfaceVariant = PerplexityColors.LightSurface2,
    outline        = PerplexityColors.LightDivider,
    error          = PerplexityColors.Error,
)

@Composable
fun PerplexityTheme(
    dark: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) = MaterialTheme(
    colorScheme = if (dark) PerplexityDark else PerplexityLight,
    typography = PerplexityTypography,
    content = content,
)

2. Typography

Perplexity is a two-face system: FK Grotesk Neue (Florian Karsten — chrome/brand voice) and Inter (answer body). Drop both in res/font/; FK Grotesk Mono drives citation numerals. Fall back to Inter for both (closest geometric-humanist substitute that ships everywhere). Code uses JetBrains Mono.

// ui/theme/PerplexityType.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 FKGrotesk = FontFamily(
    Font(R.font.fk_grotesk_neue_regular,  FontWeight.Normal),
    Font(R.font.fk_grotesk_neue_medium,   FontWeight.Medium),
    Font(R.font.fk_grotesk_neue_semibold, FontWeight.SemiBold),
    Font(R.font.fk_grotesk_neue_bold,     FontWeight.Bold),
)
val FKGroteskMono = FontFamily(Font(R.font.fk_grotesk_mono_medium, FontWeight.Medium))
val Inter = FontFamily(
    Font(R.font.inter_regular,  FontWeight.Normal),
    Font(R.font.inter_medium,   FontWeight.Medium),
    Font(R.font.inter_semibold, FontWeight.SemiBold),
    Font(R.font.inter_bold,     FontWeight.Bold),
)
val JetBrainsMono = FontFamily(
    Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
    Font(R.font.jetbrains_mono_medium,  FontWeight.Medium),
)

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object PerplexityText {
    val Display     = TextStyle(FKGrotesk, fontWeight = FontWeight.Bold,     fontSize = 30.sp, lineHeight = 35.sp, letterSpacing = (-0.5).sp)
    val Question    = TextStyle(FKGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.3).sp)
    val Section     = TextStyle(FKGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
    val Subsection  = TextStyle(FKGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 21.sp)
    val Body        = TextStyle(Inter,     fontWeight = FontWeight.Normal,   fontSize = 16.sp, lineHeight = 24.sp) // 1.5 — denser than Claude
    val BodyBold    = TextStyle(Inter,     fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 24.sp)
    val ListItem    = TextStyle(Inter,     fontWeight = FontWeight.Normal,   fontSize = 16.sp, lineHeight = 24.sp)
    val SourceTitle = TextStyle(Inter,     fontWeight = FontWeight.Medium,   fontSize = 13.sp, lineHeight = 17.sp)
    val SourceDom   = TextStyle(Inter,     fontWeight = FontWeight.Normal,   fontSize = 11.sp, lineHeight = 14.sp)
    val Citation    = TextStyle(FKGroteskMono, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 11.sp)
    val SearchHint  = TextStyle(Inter,     fontWeight = FontWeight.Normal,   fontSize = 16.sp, lineHeight = 22.sp)
    val Button      = TextStyle(FKGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 15.sp)
    val ProBadge    = TextStyle(FKGrotesk, fontWeight = FontWeight.Bold,     fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.2.sp)
    val Tab         = TextStyle(FKGrotesk, fontWeight = FontWeight.Medium,   fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.2.sp)
    val Meta        = TextStyle(Inter,     fontWeight = FontWeight.Normal,   fontSize = 12.sp, lineHeight = 16.sp)
    val Caption     = TextStyle(Inter,     fontWeight = FontWeight.Normal,   fontSize = 11.sp, lineHeight = 14.sp)
    val SenderLabel = TextStyle(FKGrotesk, fontWeight = FontWeight.Medium,   fontSize = 13.sp, lineHeight = 17.sp)
    val GroupHeader = TextStyle(FKGrotesk, fontWeight = FontWeight.Bold,     fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.4.sp)
    val CodeBlock   = TextStyle(JetBrainsMono, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 21.sp)
    val CodeInline  = TextStyle(JetBrainsMono, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp)
    val CodeLang    = TextStyle(JetBrainsMono, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 14.sp)
}

// Map onto Material 3 slots so stock components inherit the brand
val PerplexityTypography = Typography(
    headlineLarge = PerplexityText.Display,
    titleLarge    = PerplexityText.Section,
    titleMedium   = PerplexityText.Subsection,
    bodyMedium    = PerplexityText.Body,
    labelSmall    = PerplexityText.Tab,
)

3. Signature Components

The Triple-Circle Brand Mark

import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.cos
import kotlin.math.sin

@Composable
fun PerplexityMark(
    size: Dp = 20.dp,
    color: Color = PerplexityColors.Teal, // NEVER any color outside the teal scale
    modifier: Modifier = Modifier,
) {
    Canvas(modifier.size(size)) {
        val c = Offset(this.size.width / 2f, this.size.height / 2f)
        val r = minOf(this.size.width, this.size.height) * 0.32f
        val d = r * 0.65f
        // Three overlapping circles in a triangular formation
        val angles = floatArrayOf(-Math.PI.toFloat() / 2f, Math.PI.toFloat() / 6f, 5f * Math.PI.toFloat() / 6f)
        angles.forEach { a ->
            drawCircle(
                color = color,
                radius = r,
                center = Offset(c.x + d * cos(a), c.y + d * sin(a)),
                style = Stroke(width = r * 0.5f),
            )
        }
    }
}

Search Input (the home of the app, with teal focus glow)

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback

@Composable
fun SearchInput(
    text: String,
    onTextChange: (String) -> Unit,
    onSubmit: () -> Unit,
    onAttach: () -> Unit,
    modifier: Modifier = Modifier,
) {
    var focused by remember { mutableStateOf(false) }
    val canSend = text.trim().isNotEmpty()
    val haptics = LocalHapticFeedback.current

    Row(
        modifier = modifier
            .fillMaxWidth()
            // Teal focus glow — the only "glow" in the app (Android has no live blur; a teal
            // halo border is the analog of the iOS rgba(32,184,205,0.25) 4pt ring)
            .then(if (focused) Modifier.border(4.dp, PerplexityColors.Teal.copy(alpha = 0.25f), RoundedCornerShape(28.dp)) else Modifier)
            .clip(RoundedCornerShape(24.dp))
            .background(PerplexityColors.Surface1)
            .border(
                width = if (focused) 1.5.dp else 1.dp,
                color = if (focused) PerplexityColors.Teal else PerplexityColors.Surface3,
                shape = RoundedCornerShape(24.dp),
            )
            .padding(horizontal = 18.dp),
        verticalAlignment = Alignment.Bottom,
        horizontalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        BasicTextField(
            value = text,
            onValueChange = onTextChange,
            textStyle = PerplexityText.SearchHint.copy(color = PerplexityColors.TextPrimary),
            cursorBrush = androidx.compose.ui.graphics.SolidColor(PerplexityColors.Teal),
            modifier = Modifier
                .weight(1f)
                .padding(vertical = 14.dp)
                .onFocusChanged { focused = it.isFocused },
            maxLines = 8,
            decorationBox = { inner ->
                if (text.isEmpty()) Text("Ask anything…", style = PerplexityText.SearchHint, color = PerplexityColors.TextTertiary)
                inner()
            },
        )
        Icon(
            Icons.Filled.AttachFile, contentDescription = "Attach",
            tint = PerplexityColors.TextSecondary,
            modifier = Modifier.padding(bottom = 14.dp).size(18.dp)
                .clickable(MutableInteractionSource(), indication = null, onClick = onAttach),
        )
        Box(
            Modifier
                .padding(bottom = 10.dp)
                .size(36.dp)
                .clip(CircleShape)
                .background(if (canSend) PerplexityColors.Teal else PerplexityColors.Surface3)
                .clickable(enabled = canSend) {
                    haptics.performHapticFeedback(HapticFeedbackType.LongPress) // .medium
                    onSubmit()
                },
            contentAlignment = Alignment.Center,
        ) {
            Icon(
                Icons.Filled.ArrowUpward,
                contentDescription = "Send",
                tint = if (canSend) PerplexityColors.Canvas else PerplexityColors.TextTertiary, // dark on teal
                modifier = Modifier.size(14.dp),
            )
        }
    }
}

Source Card (horizontal scroll above every answer)

import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage

data class Source(val number: Int, val faviconUrl: String?, val domain: String, val title: String)

@Composable
fun SourceCard(source: Source, onClick: () -> Unit, modifier: Modifier = Modifier) {
    Column(
        modifier
            .width(200.dp)
            .height(80.dp)
            .clip(RoundedCornerShape(12.dp))
            .background(PerplexityColors.Surface1)
            .border(1.dp, PerplexityColors.Surface3, RoundedCornerShape(12.dp))
            .clickable(onClick = onClick)
            .padding(12.dp),
        verticalArrangement = Arrangement.spacedBy(6.dp),
    ) {
        Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
            AsyncImage(
                model = source.faviconUrl,
                contentDescription = null,
                modifier = Modifier.size(16.dp).clip(RoundedCornerShape(3.dp)),
                contentScale = ContentScale.Crop,
            )
            Text(source.domain, style = PerplexityText.SourceDom, color = PerplexityColors.TextSecondary, modifier = Modifier.weight(1f), maxLines = 1)
            Box(
                Modifier.clip(RoundedCornerShape(3.dp)).background(PerplexityColors.Surface2).padding(horizontal = 5.dp, vertical = 1.dp),
            ) {
                Text("${source.number}", style = PerplexityText.Citation.copy(fontWeight = FontWeight.Bold, fontSize = 10.sp), color = PerplexityColors.TextSecondary)
            }
        }
        Text(source.title, style = PerplexityText.SourceTitle, color = PerplexityColors.TextPrimary, maxLines = 2)
    }
}

@Composable
fun SourceCardRow(sources: List<Source>, onSource: (Source) -> Unit, onShowAll: () -> Unit) {
    LazyRow(
        contentPadding = PaddingValues(horizontal = 16.dp),
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        itemsIndexed(sources) { _, s -> SourceCard(s, { onSource(s) }) }
        item {
            Box(
                Modifier
                    .width(120.dp).height(80.dp)
                    .clip(RoundedCornerShape(12.dp))
                    .background(PerplexityColors.Surface2)
                    .clickable(onClick = onShowAll),
                contentAlignment = Alignment.Center,
            ) {
                Text("Show all ${sources.size} sources", style = PerplexityText.SourceDom, color = PerplexityColors.TextSecondary)
            }
        }
    }
}

4. Inline Citation Chips + Source-Card Scroll-To

Perplexity’s defining interaction: numbered [1] chips typeset inline within the answer prose (never a footer), each tapping through to its matching source card with a teal flash. iOS uses ScrollViewReader.scrollTo; Android uses a LazyListState/scroll-to-index plus an Animatable border flash on the target card.

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween

@Composable
fun CitationChip(
    number: Int,
    onTap: () -> Unit,
    modifier: Modifier = Modifier,
) {
    var focused by remember { mutableStateOf(false) }
    val haptics = LocalHapticFeedback.current

    Box(
        modifier
            .padding(2.dp) // expands toward a ~32dp tap target
            .clip(RoundedCornerShape(4.dp))
            .background(if (focused) PerplexityColors.TealSoft else PerplexityColors.Surface2)
            .border(1.dp, if (focused) PerplexityColors.Teal else PerplexityColors.Surface3, RoundedCornerShape(4.dp))
            .clickable {
                focused = true
                haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // .soft
                onTap()
            }
            .padding(horizontal = 5.dp)
            .heightIn(min = 18.dp),
        contentAlignment = Alignment.Center,
    ) {
        Text(
            "$number",
            style = PerplexityText.Citation,
            color = if (focused) PerplexityColors.Teal else PerplexityColors.TextSecondary,
        )
    }
    // Caller resets `focused` ~300ms after the scroll completes
}

// Render answer prose with tappable chips: split markdown into text runs + [n] tokens and
// emit a Text(...) interleaved with CitationChip(...) inside a FlowRow so they wrap inline.
@Composable
fun rememberCitationScroll(): Pair<LazyListState, suspend (Int) -> Unit> {
    val state = androidx.compose.foundation.lazy.rememberLazyListState()
    val flash = remember { Animatable(0f) }
    val scrollTo: suspend (Int) -> Unit = { index ->
        state.animateScrollToItem(index) // 400ms-feel ease handled by LazyList
        flash.snapTo(1f); flash.animateTo(0f, tween(200)) // 200ms teal border flash on arrival
    }
    return state to scrollTo
}

The streaming answer ends with a teal caret: a 6.dp × 16.dp TealBright box blinking on a rememberInfiniteTransition at a 500ms interval (faster than Claude’s 600ms), removed when the answer completes. The “Searching the web…” indicator (3 teal dots, 200ms stagger, 1200ms cycle, SenderLabel text trailing) shows immediately on submit and is replaced by SourceCardRow.

5. Navigation

Perplexity has a 4-tab bottom bar (Home / Discover / Library / Spaces). Use Material 3 NavigationBar; the iOS bar matches the canvas with no blur on scroll, so an opaque Canvas surface is exact (no translucency needed here). Active is off-white #FAFAFA with a teal indicator dot under the label; inactive #6E6E6E.

import androidx.compose.material.icons.filled.*

@Composable
fun PerplexityBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    val haptics = LocalHapticFeedback.current
    NavigationBar(
        containerColor = PerplexityColors.Canvas,
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            "Home"     to Icons.Filled.Search,
            "Discover" to Icons.Filled.Explore,
            "Library"  to Icons.Filled.Bookmarks,
            "Spaces"   to Icons.Filled.Workspaces,
        )
        items.forEachIndexed { i, (label, icon) ->
            val active = selected == i
            NavigationBarItem(
                selected = active,
                onClick = {
                    haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // .selection
                    onSelect(i)
                },
                icon = {
                    Column(horizontalAlignment = Alignment.CenterHorizontally) {
                        Icon(icon, contentDescription = label, modifier = Modifier.size(22.dp))
                        Spacer(Modifier.height(4.dp))
                        Box(
                            Modifier.size(width = 16.dp, height = 2.dp)
                                .background(if (active) PerplexityColors.Teal else Color.Transparent),
                        )
                    }
                },
                label = { Text(label, style = PerplexityText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor = PerplexityColors.TextPrimary,   // off-white
                    selectedTextColor = PerplexityColors.TextPrimary,
                    unselectedIconColor = PerplexityColors.TextTertiary, // #6E6E6E
                    unselectedTextColor = PerplexityColors.TextTertiary,
                    indicatorColor = Color.Transparent, // no Material pill — Perplexity uses a teal dot
                ),
            )
        }
    }
}

The conversation-history sidebar is a ModalNavigationDrawer (ModalDrawerSheet, Canvas background) with a full-width “+ New thread” button (Surface1 fill, 1.dp Surface3 border, 10.dp radius) followed by date-grouped thread rows (GroupHeader uppercase headers, 56.dp rows with Inter title + timestamp).

6. Motion

Perplexity motion is live and information-paced — slightly faster than Claude, restrained but responsive.

Moment Compose recipe
Send tap circle scale 1 → 0.94 → 1 tween(200) + HapticFeedbackType.LongPress (medium); question slides up, “Searching…” fades in tween(250)
Source cards appear LazyRow items animateItemPlacement + per-item slideInVertically(tween(350)) with 60ms stagger (cascade)
Citation → source LazyListState.animateScrollToItem + target card Animatable border flash tween(200) teal
Streaming text word-by-word AnimatedVisibility/fadeIn at ~60ms per word
Streaming cursor rememberInfiniteTransition alpha 1 → 0, 500ms
Pro toggle indicator animateDpAsState slide tween(200), label color cross-fade, .selection haptic
Pro Steps expand AnimatedVisibility(expandVertically(tween(300))), 30ms per-step stagger
Tab switch teal dot fadeIn tween(200), .selection haptic
// "Searching the web…" — three teal dots, 200ms stagger, 1200ms cycle
@Composable
fun SearchingIndicator() {
    val t = rememberInfiniteTransition(label = "searching")
    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
            repeat(3) { i ->
                val s by t.animateFloat(
                    1f, 1.3f,
                    infiniteRepeatable(tween(600, delayMillis = i * 200), RepeatMode.Reverse),
                    label = "dot$i",
                )
                Box(
                    Modifier.size(6.dp).graphicsLayer { scaleX = s; scaleY = s }
                        .clip(CircleShape).background(PerplexityColors.Teal),
                )
            }
        }
        Text("Searching the web…", style = PerplexityText.SenderLabel, color = PerplexityColors.TextSecondary)
    }
}

Haptics: prefer LocalHapticFeedback. Use HapticFeedbackType.LongPress for the medium send impact and HapticFeedbackType.TextHandleMove for the soft citation tap; for finer control use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) or a Vibrator VibrationEffect.createOneShot(8, ...).

7. Icons

Perplexity ships custom glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. The triple-circle mark is NOT an icon — draw it via Canvas (§3) and never tint it outside the teal scale.

Purpose SF Symbol (iOS) Material Icon (Compose)
Send arrow.up Icons.Filled.ArrowUpward
Attach paperclip Icons.Filled.AttachFile
Copy doc.on.doc Icons.Filled.ContentCopy
Share square.and.arrow.up Icons.Filled.IosShare
Save bookmark / bookmark.fill Icons.Outlined.BookmarkBorder / Icons.Filled.Bookmark
Pro sparkles sparkles Icons.Filled.AutoAwesome
Step check checkmark.circle.fill Icons.Filled.CheckCircle
Chevron up/down chevron.up / chevron.down Icons.Filled.KeyboardArrowUp / KeyboardArrowDown
Related arrow arrow.right Icons.Filled.ArrowForward
Home (tab) magnifyingglass Icons.Filled.Search
Discover (tab) safari Icons.Filled.Explore
Library (tab) books.vertical Icons.Filled.Bookmarks
Spaces (tab) square.stack.3d.down.right Icons.Filled.Workspaces
Sidebar line.3.horizontal Icons.Filled.Menu
Overflow ellipsis Icons.Filled.MoreHoriz
Settings gearshape Icons.Filled.Settings
Show all sources chevron.right Icons.Filled.ChevronRight

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; Canvas mark + infinite-transition motion are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); the near-black canvas wants light-content system bars (dark-content in light mode). The 56dp header sits below the camera cutout; the search input pins above the IME — use Modifier.imePadding() so it never hides behind the keyboard; the tab bar clears the gesture nav via Scaffold insets.
  • Font scaling: sp honors the user’s font scale — keep it on answer body, all headings, list items, source titles, question titles, related questions. Code scales but cap it (fontSize clamp at 18sp) to prevent overflow. Pin layout-sensitive text (citation chips, 11sp source domains, 10sp tab labels, code-language label) by deriving from dp or CompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)).
  • TalkBack: the answer block announces “Answer from Perplexity” then the body once; citation chips read “Citation 1, button — opens source 1: {title}” via Modifier.semantics { ... } + a custom “Open source” action so users reach the source without scrolling; source cards read “Source 1, {domain}, {title}, button”. Announce “Generating answer” once on stream start and “Answer complete” on finalize — never per token.
  • Touch targets: Material guidance is 48.dp minimum. The 36.dp send circle gets a 48–56.dp hit area; the 18.dp citation chip is wrapped to a ~32–48.dp target via padding; source cards and related-question rows are full-row tappable.
  • Contrast: #FAFAFA on #0A0A0A exceeds WCAG AAA; #A1A1A1 meets AA at 14sp+; #6E6E6E only at 18sp+ — do not use TextTertiary on small body text. Teal CTAs use Canvas #0A0A0A text (reversed from typical) for AA; in light mode the teal darkens to #1591A3.
  • Reduce motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0, skip the searching-dot pulse, the source-card cascade, the streaming-cursor blink, and the focus-ring glow — preserve haptics.
  • Dynamic / dark color: do not enable Material You dynamicColorScheme() — Perplexity’s single teal accent and near-black canvas are brand-load-bearing and must not retint to the user’s wallpaper. The triple-circle mark must stay within the teal scale (#20B8CD / #3DD6EC / #1591A3) in every theme. Light mode flips canvas/surface/text and darkens teal to #1591A3 for WCAG; everything else is preserved.
All mobile designs →

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