BeReal (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports BeReal’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 (BeReal’s pure-black darkroom, the monochrome no-brand-hue stance, the dual-lens composite, RealMoji reactions) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,CameraXinstead of AVFoundation,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for images, and CameraX for the dual capture.
1. Color Tokens
// ui/theme/BeRealColors.kt
import androidx.compose.ui.graphics.Color
object BeRealColors {
// Canvas & Surfaces
val Canvas = Color(0xFF000000)
val Surface1 = Color(0xFF1C1C1E)
val Surface2 = Color(0xFF2C2C2E)
val Divider = Color(0xFF2C2C2E)
// Text
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFF8E8E93)
val TextTertiary = Color(0xFF636366)
// Accent — intentionally monochrome, no brand hue
val Accent = Color(0xFFFFFFFF)
val OnAccent = Color(0xFF000000)
// Semantic
val Late = Color(0xFFFFD60A)
val Error = Color(0xFFFF453A)
val Online = Color(0xFF30D158)
}
Wire it into a Material 3 darkColorScheme. BeReal is dark-only by design — the photographic darkroom metaphor depends on true black; do not provide a light scheme.
// ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
private val BeRealScheme = darkColorScheme(
primary = BeRealColors.Accent,
onPrimary = BeRealColors.OnAccent, // black on white — intentional inversion
background = BeRealColors.Canvas,
onBackground = BeRealColors.TextPrimary,
surface = BeRealColors.Surface1,
onSurface = BeRealColors.TextPrimary,
surfaceVariant = BeRealColors.Surface2,
outline = BeRealColors.Divider,
error = BeRealColors.Error,
)
@Composable
fun BeRealTheme(content: @Composable () -> Unit) =
MaterialTheme(colorScheme = BeRealScheme, typography = BeRealTypography, content = content)
2. Typography
BeReal ships no custom typeface — it uses the system font. On Android that is Roboto; reference FontFamily.Default so there is nothing to drop in res/font/.
// ui/theme/BeRealType.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
private val Sys = FontFamily.Default // BeReal uses the system font, no custom face
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object BeRealText {
val Timer = TextStyle(Sys, fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 26.sp) // use tabular figures via FontFeatureSettings("tnum")
val ScreenTitle = TextStyle(Sys, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val Username = TextStyle(Sys, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 20.sp, letterSpacing = (-0.2).sp)
val Caption = TextStyle(Sys, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 22.sp)
val Body = TextStyle(Sys, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 21.sp)
val CommentAuthor= TextStyle(Sys, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 18.sp)
val Meta = TextStyle(Sys, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val LateBadge = TextStyle(Sys, fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 14.sp, letterSpacing = 0.2.sp)
val Button = TextStyle(Sys, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 20.sp, letterSpacing = (-0.2).sp)
val Tab = TextStyle(Sys, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 12.sp)
val Retake = TextStyle(Sys, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 13.sp)
}
// Map onto Material 3 slots so stock components inherit the system-font, dark identity
val BeRealTypography = Typography(
headlineMedium = BeRealText.ScreenTitle,
titleMedium = BeRealText.Username,
bodyMedium = BeRealText.Body,
labelSmall = BeRealText.Tab,
)
3. Signature Components
Dual-Lens Composite Card (the signature)
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.shape.RoundedCornerShape
import coil.compose.AsyncImage
enum class SelfieCorner { TopStart, TopEnd, BottomStart, BottomEnd }
@Composable
fun DualLensCard(backUrl: String, frontUrl: String, modifier: Modifier = Modifier) {
var corner by remember { mutableStateOf(SelfieCorner.TopStart) }
var lifted by remember { mutableStateOf(false) }
val scale by androidx.compose.animation.core.animateFloatAsState(
if (lifted) 1.05f else 1f,
androidx.compose.animation.core.spring(dampingRatio = 0.75f, stiffness = 300f),
label = "selfieLift",
)
Box(
modifier
.fillMaxWidth()
.aspectRatio(3f / 4f)
.shadow(24.dp, RoundedCornerShape(18.dp), spotColor = androidx.compose.ui.graphics.Color.Black.copy(0.6f))
.clip(RoundedCornerShape(18.dp)),
) {
AsyncImage(model = backUrl, contentDescription = "BeReal back photo",
modifier = Modifier.fillMaxSize())
AsyncImage(
model = frontUrl,
contentDescription = "BeReal selfie",
modifier = Modifier
.align(corner.toAlignment())
.padding(12.dp)
.size(100.dp, 134.dp)
.scale(scale)
.shadow(if (lifted) 18.dp else 0.dp, RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(14.dp))
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { lifted = true },
onDragEnd = {
lifted = false
corner = SelfieCorner.entries.random()
},
) { _, _ -> }
},
)
}
}
private fun SelfieCorner.toAlignment() = when (this) {
SelfieCorner.TopStart -> Alignment.TopStart
SelfieCorner.TopEnd -> Alignment.TopEnd
SelfieCorner.BottomStart -> Alignment.BottomStart
SelfieCorner.BottomEnd -> Alignment.BottomEnd
}
Capture Button (Dual Shutter)
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun CaptureButton(onCapture: () -> Unit, modifier: Modifier = Modifier) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
val scale by androidx.compose.animation.core.animateFloatAsState(if (pressed) 0.92f else 1f, label = "capScale")
val disc by androidx.compose.animation.core.animateDpAsState(if (pressed) 64.dp else 0.dp, label = "capDisc")
Box(
modifier
.size(76.dp)
.scale(scale)
.clip(androidx.compose.foundation.shape.CircleShape)
.border(4.dp, BeRealColors.Accent, androidx.compose.foundation.shape.CircleShape)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // heavy ≈ back camera
onCapture()
scope.launch {
delay(500)
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // soft ≈ front
}
},
contentAlignment = Alignment.Center,
) {
Box(Modifier.size(disc).clip(androidx.compose.foundation.shape.CircleShape).background(BeRealColors.Accent))
}
}
Primary / Secondary Button
enum class BeRealBtn { Filled, Outline }
@Composable
fun BeRealButton(text: String, onClick: () -> Unit, style: BeRealBtn = BeRealBtn.Filled, modifier: Modifier = Modifier) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by androidx.compose.animation.core.animateFloatAsState(if (pressed) 0.97f else 1f, label = "btn")
val filled = style == BeRealBtn.Filled
Box(
modifier
.scale(scale)
.clip(androidx.compose.foundation.shape.CircleShape)
.then(
if (filled) Modifier.background(BeRealColors.Accent)
else Modifier.border(1.dp, BeRealColors.TextPrimary.copy(alpha = 0.4f), androidx.compose.foundation.shape.CircleShape)
)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = if (filled) 32.dp else 24.dp, vertical = if (filled) 14.dp else 12.dp),
contentAlignment = Alignment.Center,
) {
Text(text, style = BeRealText.Button,
color = if (filled) BeRealColors.OnAccent else BeRealColors.TextPrimary)
}
}
Countdown Banner & Late Badge
import androidx.compose.ui.text.font.FontFeature
import androidx.compose.ui.text.style.TextAlign
@Composable
fun CountdownBanner(remaining: String, modifier: Modifier = Modifier) {
// Android has no live blur — use a 60%-opaque black pill with the amber border
Box(
modifier
.fillMaxWidth()
.clip(androidx.compose.foundation.shape.CircleShape)
.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.6f))
.border(1.dp, BeRealColors.Late.copy(alpha = 0.4f), androidx.compose.foundation.shape.CircleShape)
.padding(horizontal = 20.dp, vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(
"⏰ $remaining left to capture a BeReal",
style = BeRealText.Timer.copy(fontFeatureSettings = "tnum"), // tabular digits
color = BeRealColors.Late,
textAlign = TextAlign.Center,
)
}
}
@Composable
fun LateBadge(text: String) =
Text("⚠ $text", style = BeRealText.LateBadge, color = BeRealColors.Late)
RealMoji Reaction Cluster
@Composable
fun RealMojiCluster(selfieUrls: List<String>, onAdd: () -> Unit, modifier: Modifier = Modifier) {
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
selfieUrls.forEachIndexed { i, url ->
AsyncImage(
model = url,
contentDescription = "Reaction", // never a count — reactions are faces
modifier = Modifier
.offset(x = if (i == 0) 0.dp else (-8 * i).dp) // overlap by 8dp
.size(30.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.border(2.dp, androidx.compose.ui.graphics.Color.Black, androidx.compose.foundation.shape.CircleShape),
)
}
Box(
Modifier
.padding(start = 12.dp)
.size(30.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.border(1.5.dp, BeRealColors.TextPrimary.copy(alpha = 0.6f), androidx.compose.foundation.shape.CircleShape)
.clickable(onClick = onAdd),
contentAlignment = Alignment.Center,
) { Text("+", style = BeRealText.Button, color = BeRealColors.TextPrimary) }
}
}
4. Distinctive System — Selfie Corner-Snap in Compose
The front selfie inset is the soul of BeReal. DualLensCard (above) wires detectDragGestures: drag start sets lifted = true (scale animates to 1.05 via spring(dampingRatio = 0.75f)), drag end snaps corner to a SelfieCorner and the Alignment recomposes. A non-drag tap on the selfie swaps which camera is full-bleed — animate a Crossfade between the two AsyncImages. Keep the snap spring at dampingRatio = 0.75f, stiffness = 300f to match the iOS feel.
5. Bottom Navigation (Tab Bar)
Use Material 3 NavigationBar. BeReal’s iOS tab bar is opaque true-black with no blur — Android has no first-class live blur anyway, so a solid #000000 surface is an exact match. Active tint is white (BeReal has no brand hue); kill the Material indicator pill.
@Composable
fun BeRealBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = BeRealColors.Canvas, tonalElevation = 0.dp) {
val items = listOf(
"Friends" to Icons.Filled.People,
"Official" to Icons.Filled.Star,
"Search" to Icons.Filled.Search,
"Profile" to Icons.Filled.AccountCircle,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(26.dp)) },
label = { Text(label, style = BeRealText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = BeRealColors.TextPrimary, // white, no brand hue
selectedTextColor = BeRealColors.TextPrimary,
unselectedIconColor = BeRealColors.TextPrimary.copy(alpha = 0.45f),
unselectedTextColor = BeRealColors.TextPrimary.copy(alpha = 0.45f),
indicatorColor = androidx.compose.ui.graphics.Color.Transparent, // BeReal has no pill
),
)
}
}
}
6. Motion
| Moment | Compose recipe |
|---|---|
| Dual-shutter capture | white-flash overlay (AnimatedVisibility 120ms) + HapticFeedbackType.LongPress, then delay(500) + soft haptic for the front lens |
| Camera flip | graphicsLayer { rotationY = … } animated 0 → 180 over 350ms tween, light haptic |
| Selfie drag-snap | animateFloatAsState/alignment recompose with spring(dampingRatio = 0.75f, stiffness = 300f) |
| RealMoji pop | Animatable keyframes 0 → 1.2 → 1 over 280ms |
| Feed un-blur | friends’ cards Modifier.blur(30.dp) → 0.dp once you post, animateDpAsState 400ms |
// Camera flip
val rot by animateFloatAsState(if (flipped) 180f else 0f, tween(350), label = "flip")
Box(Modifier.graphicsLayer { rotationY = rot; cameraDistance = 12f * density }) { /* viewfinder */ }
// RealMoji pop
val pop = remember { Animatable(0f) }
LaunchedEffect(Unit) {
pop.animateTo(1.2f, tween(160)); pop.animateTo(1f, spring())
}
Haptics: prefer LocalHapticFeedback. For the heavy capture impact use LocalView.current.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) or a Vibrator VibrationEffect.createOneShot(20, 255); follow with a lighter CONTEXT_CLICK ~500ms later for the front camera.
7. Icons
BeReal uses simple glyphs; the closest first-party set is androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Camera flip | arrow.triangle.2.circlepath.camera |
Icons.Filled.FlipCameraIos |
| Flash | bolt.fill / bolt.slash.fill |
Icons.Filled.FlashOn / Icons.Filled.FlashOff |
| Late badge | exclamationmark.triangle.fill |
Icons.Filled.Warning |
| Add RealMoji | plus |
Icons.Filled.Add |
| Comments | bubble.left |
Icons.Outlined.ChatBubbleOutline |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| More | ellipsis |
Icons.Filled.MoreHoriz |
| Friends (tab) | person.2.fill |
Icons.Filled.People |
| Official (tab) | star.circle.fill |
Icons.Filled.Star |
| Search (tab) | magnifyingglass |
Icons.Filled.Search |
| Profile (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
| BTS video | play.rectangle.on.rectangle |
Icons.Filled.Collections |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21;
CameraXdual capture + modern motion are comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the pure-black canvas wants light-content system bars viaWindowCompat. Let the dual-lens card draw edge-to-edge horizontally (Modifier.fillMaxWidth(), no inset) while keeping the tab bar insidesystemBarsinsets. - Font scaling:
sphonors the user’s font scale — keep it on usernames, captions, comments. Pin the countdown (fontFeatureSettings = "tnum"and a fixed size), late badge, retake count, and tab labels by deriving fromdpor aCompositionLocalProvider(LocalDensity …, fontScale = 1f). - TalkBack: set
contentDescriptionon the dual-lens card (“jordan’s BeReal, 2 hours late”); mark the selfie as a draggable element with a customstateDescription; announce RealMojis as “Reaction from <name>” and never a numeric count — there is none. - Touch targets: Material guidance is 48.dp minimum. The 76.dp capture button is well clear; give the 30.dp RealMoji
+a 48.dp hit area via padding. - Contrast:
#8E8E93on#000000passes WCAG AA at 13sp+. The amber#FFD60Aon black is high-contrast — reserve it for lateness/urgency only. - Dynamic color: do not enable Material You
dynamicDarkColorScheme()— BeReal’s identity is the fixed pure-black canvas and the deliberate absence of a brand hue, regardless of wallpaper.