| name | mobile-ui-patterns |
| description | Design tokens, components (DepthCard, DepthButton, HapticTouchable, Mascot), typography, spacing, safe area. Load when creating/editing UI components. |
Mobile UI Patterns
Design Tokens
Source of truth: src/theme/colors.ts — synced with frontend-v3 src/styles.css @theme
import { colors, spacing, radius, fontSize, fontFamily } from "@/theme";
Colors (never hardcode hex)
colors.light.primary
colors.light.primaryForeground
colors.light.primaryDark
colors.light.primaryTint
colors.light.destructive
colors.light.destructiveTint
colors.light.warning
colors.light.warningTint
colors.light.success
colors.light.info
colors.light.infoTint
colors.light.skillListening
colors.light.skillReading
colors.light.skillWriting
colors.light.skillSpeaking
colors.light.background
colors.light.surface
colors.light.muted
colors.light.foreground
colors.light.subtle
colors.light.border
colors.light.placeholder
colors.light.streak
colors.light.streakTint
colors.light.coin
colors.light.coinDark
colors.light.coinTint
Spacing
spacing.xs
spacing.sm
spacing.md
spacing.base
spacing.lg
spacing.xl
spacing["2xl"]
spacing["3xl"]
Radius
radius.sm
radius.md
radius.button
radius.lg
radius.xl
radius["2xl"]
radius.full
Typography
fontSize.xs
fontSize.sm
fontSize.base
fontSize.lg
fontSize.xl
fontSize["2xl"]
fontSize["3xl"]
fontFamily.regular
fontFamily.medium
fontFamily.semiBold
fontFamily.bold
fontFamily.extraBold
Components
DepthCard
3D card effect — border-2 border-b-4 synced with FE v3 .card
import { DepthCard } from "@/components/DepthCard";
<DepthCard variant="neutral" padding={spacing.lg}>
{/* content */}
</DepthCard>
<DepthCard variant="skill" skillColor={colors.light.skillListening}>
<Text>Listening Practice</Text>
</DepthCard>
<DepthCard variant="primary"> // Green tint
<DepthCard variant="success"> // Same as primary
<DepthCard variant="destructive"> // Red tint
DepthButton
3D press effect — synced with FE v3 .btn-primary
import { DepthButton } from "@/components/DepthButton";
<DepthButton variant="primary" size="lg" fullWidth onPress={handleStart}>
Begin
</DepthButton>
<DepthButton variant="secondary"> // White bg
<DepthButton variant="destructive"> // Red bg
<DepthButton variant="coin"> // Gold bg
<DepthButton variant="info"> // Blue bg
// Sizes
<DepthButton size="sm"> // height: 36
<DepthButton size="md"> // height: 44
<DepthButton size="lg"> // height: 52
HapticTouchable
Replaces TouchableOpacity with haptic feedback
import { HapticTouchable } from "@/components/HapticTouchable";
<HapticTouchable onPress={handleTap}>
<View>
<Text>Tap me</Text>
</View>
</HapticTouchable>
<HapticTouchable scalePress onPress={handleTap}>
<Animated.View>
<Text>Bouncy button</Text>
</Animated.View>
</HapticTouchable>
<HapticTouchable haptic={false} onPress={handleTap}>
Mascot
"Lạc" character — use sparingly, appropriate expression
import { Mascot } from "@/components/Mascot";
<Mascot name="hero" size={120} animation="float" />
<Mascot name="happy" size={80} animation="bounce" />
<Mascot name="think" size={60} animation="pop" />
<Mascot name="listen" size={100} animation="float" />
<Mascot name="read" size={100} animation="float" />
<Mascot name="write" size={100} animation="float" />
<Mascot name="speak" size={100} animation="float" />
<Mascot name="vocabulary" size={100} animation="float" />
<Mascot name="levelup" size={140} animation="bounce" />
<Mascot name="sad" size={80} animation="none" />
animation="float"
animation="bounce"
animation="pop"
animation="none"
Rules:
- ✅ Onboarding screens
- ✅ Empty states
- ✅ Celebrations (complete exam, streak milestone)
- ❌ Do NOT loop animation on every interaction
- ❌ Do NOT use mascot as background
- ❌ Do NOT overlap mascot with important content
SpiderChart
Radar chart for skill scores
import { SpiderChart } from "@/components/SpiderChart";
<SpiderChart
data={[listening, reading, writing, speaking]}
target={[targetL, targetR, targetW, targetS]}
size={200}
/>
- 4 axes: Listening, Reading, Writing, Speaking
- Scale: 0-10
- Current: primary color polygon
- Target: destructive dashed polygon
- Only shows when ≥ 5 exams completed
GameIcon & SkillIcon
System icons — from assets/icons/*.png
import { GameIcon } from "@/components/GameIcon";
import { SkillIcon } from "@/components/SkillIcon";
<GameIcon name="trophy" size={24} />
<GameIcon name="fire" size={24} />
<GameIcon name="gem" size={24} />
<SkillIcon name="listening" size={32} />
<SkillIcon name="reading" size={32} />
<SkillIcon name="writing" size={32} />
<SkillIcon name="speaking" size={32} />
<SkillIcon name="vocabulary" size={32} />
Safe Area
MANDATORY for all scrollable content
import { useSafeAreaInsets } from "react-native-safe-area-context";
const insets = useSafeAreaInsets();
<ScrollView
contentContainerStyle={{
paddingBottom: insets.bottom + spacing["2xl"]
}}
>
{/* content */}
</ScrollView>
<View style={{ paddingBottom: insets.bottom + spacing.lg }}>
<DepthButton>Begin</DepthButton>
</View>
Layout Patterns
Scrollable Screen
export default function Screen() {
const c = useThemeColors();
const insets = useSafeAreaInsets();
return (
<ScrollView
style={[styles.root, { backgroundColor: c.background }]}
contentContainerStyle={{
padding: spacing.lg,
paddingBottom: insets.bottom + spacing["2xl"]
}}
>
<DepthCard>
<Text style={{ fontFamily: fontFamily.bold, fontSize: fontSize.xl }}>
Title
</Text>
</DepthCard>
</ScrollView>
);
}
Fixed Bottom CTA
export default function Screen() {
const insets = useSafeAreaInsets();
return (
<View style={{ flex: 1 }}>
<ScrollView contentContainerStyle={{ paddingBottom: spacing["3xl"] }}>
{/* content */}
</ScrollView>
<View style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: spacing.lg,
paddingBottom: insets.bottom + spacing.lg,
backgroundColor: c.surface,
borderTopWidth: 1,
borderTopColor: c.border
}}>
<DepthButton fullWidth size="lg" onPress={handleAction}>
Action
</DepthButton>
</View>
</View>
);
}
Fade In Animation
const fadeAnims = useRef(
Array.from({ length: count }, () => new Animated.Value(0))
).current;
useEffect(() => {
fadeAnims.forEach((anim, i) => {
Animated.timing(anim, {
toValue: 1,
duration: 400,
delay: i * 80,
useNativeDriver: true,
}).start();
});
}, []);
<Animated.View style={{ opacity: fadeAnims[i] }}>
<DepthCard>...</DepthCard>
</Animated.View>
Anti-patterns
❌ WRONG: Hardcoded colors
<View style={{ backgroundColor: "#58CC02" }}>
✅ RIGHT: Use theme tokens
<View style={{ backgroundColor: colors.light.primary }}>
❌ WRONG: Inline styles without tokens
<View style={{ padding: 16, borderRadius: 12, fontSize: 16 }}>
✅ RIGHT: Use spacing/radius/fontSize
<View style={{ padding: spacing.base, borderRadius: radius.lg }}>
<Text style={{ fontSize: fontSize.base }}>
❌ WRONG: Map for fixed UI sets
{SKILLS.map(s => <DepthCard key={s}>{s.label}</DepthCard>)}
✅ RIGHT: Write explicitly
<DepthCard skillColor={colors.light.skillListening}>Listening</DepthCard>
<DepthCard skillColor={colors.light.skillReading}>Reading</DepthCard>
<DepthCard skillColor={colors.light.skillWriting}>Writing</DepthCard>
<DepthCard skillColor={colors.light.skillSpeaking}>Speaking</DepthCard>