Status: Proposal. No code changed yet. Goal: Replace 40+ cryptic tokens with 12 semantic variants and one clean component.
| Problem | Impact |
|---|---|
| 40+ tokens, most duplicates | body, body-1, body-6, body-subtext are all 17px/23lh — same style, 4 names |
| Names carry no meaning | subhead-800 doesn't tell you when to use it |
| Inline overrides negate the token | <Typography type="body-7" style={{ fontSize: 12 }}> — why use a token at all? |
| 14px (242 uses) has no token | The single most-common inline size is completely unrepresented |
| 20px (41 uses) has no token | Same gap |
| Color always applied inline | style={{ color: themeColors.Text_neutralMidHigh }} — repeated in every callsite |
| Animation baked in | isAnimate lives in a text primitive where it doesn't belong |
Every existing token already uses TimelessSans-GroteskSemibold. The only exceptions are the two rupee variants (title-800-rupee, subhead-1-rupee) which use Inter-Semibold for currency symbol rendering. The new system makes this explicit: one font constant, no weight mixing.
Names describe what the text is, not how it was built:
display → hero numbers, large fares
title1 → screen-level heading
title2 → section heading in a scrollable list
title3 → card heading, prominent list item
subhead1 → callout label, prominent secondary heading
subhead2 → navigation text, secondary section label
body1 → primary readable prose
body2 → supporting text, ride metadata
label1 → form labels, button text, list items
label2 → tags, chips, badge text
caption → sub-metadata, timestamp, ride detail line
micro → SVG annotations, pass card tiny text
Color is the single most common inline override. A color prop removes 90% of style={{}} usage.
Animation is a composition concern. The caller wraps with Reanimated's Animated.createAnimatedComponent when needed.
One rupee boolean swaps fontFamily to Inter-Semibold for currency symbol rendering. No separate token needed.
All values are iOS. Android values are in the second table.
| Variant | fontSize | lineHeight | letterSpacing | Replaces (old tokens) |
|---|---|---|---|---|
display |
43 | 45 | 0.5 | body-4 |
title1 |
23 | 29 | 0 | title-800 |
title2 |
20 | 26 | 0 | (no token — 41 inline uses) |
title3 |
19 | 27 | 0 | subhead-800, subhead-700, subhead-600, title-3, subhead-4 |
subhead1 |
18 | 25 | 0.3 | callout, callout-1, subhead, subhead-1, body-2 |
subhead2 |
16 | 22 | 0.2 | subhead-2, body-3 |
body1 |
17 | 23 | 0.2 | body, body-1, body-6, body-subtext, body-8* |
body2 |
15 | 19 | 0.2 | body-7, sub-body-700, sub-body-800, sub-body-500, subhead-3 |
label1 |
14 | 20 | 0.1 | (no token — 242 inline uses) |
label2 |
13 | 15 | 0.5 | micro, body-5 |
caption |
12 | 16 | 0.2 | (no token — 85 inline uses) |
micro |
10 | 13 | 0.5 | (no token — 19 inline uses) |
*
body-8is 19px/23lh — one size step above body1. Absorbing it into body1 is lossy. Verify the 4 callsites before migrating; they may needtitle3instead.
| Variant | iOS | Android |
|---|---|---|
| display | 43 | 39 |
| title1 | 23 | 21 |
| title2 | 20 | 18 |
| title3 | 19 | 17 |
| subhead1 | 18 | 16 |
| subhead2 | 16 | 15 |
| body1 | 17 | 16 |
| body2 | 15 | 14 |
| label1 | 14 | 13 |
| label2 | 13 | 12 |
| caption | 12 | 11 |
| micro | 10 | 9 |
The existing Android tokens apply ~1.09× correction (Android text renders physically larger at the same point value). Keep this in the SCALE constant with
Platform.select, not in individual callsites.
Proposed location: consumer/src-v2/primitives/Text.tsx
import React from 'react';
import { Text as RNText, StyleSheet, Platform } from 'react-native';
import type { TextStyle, StyleProp } from 'react-native';
import type { AccessibilityRole } from 'react-native/Libraries/Components/View/ViewAccessibility';
import type { PropsWithChildren } from 'react';
// ─── Variant type ────────────────────────────────────────────────────────────
export type TextVariant =
| 'display' // 43px — hero numbers, large fare amounts
| 'title1' // 23px — screen headings
| 'title2' // 20px — section headings in scroll
| 'title3' // 19px — card/list headings
| 'subhead1' // 18px — callout labels, prominent secondary headings
| 'subhead2' // 16px — navigation text, secondary labels
| 'body1' // 17px — primary prose
| 'body2' // 15px — supporting text, metadata
| 'label1' // 14px — form labels, button text, list items
| 'label2' // 13px — chips, badges, tags
| 'caption' // 12px — sub-metadata, timestamps
| 'micro'; // 10px — SVG annotations, pass card tiny text
// ─── Props ───────────────────────────────────────────────────────────────────
type Props = PropsWithChildren<{
variant: TextVariant;
color?: string;
align?: 'left' | 'center' | 'right';
numberOfLines?: number;
/** True when displaying a rupee/currency symbol — swaps fontFamily to Inter-Semibold */
rupee?: boolean;
style?: StyleProp<TextStyle>;
accessible?: boolean;
accessibilityLabel?: string;
accessibilityRole?: AccessibilityRole;
testID?: string;
}>;
// ─── Font constants ───────────────────────────────────────────────────────────
const FONT = 'TimelessSans-GroteskSemibold';
const FONT_RUPEE = 'Inter-Semibold';
// ─── Scale ────────────────────────────────────────────────────────────────────
type ScaleValue = Pick<TextStyle, 'fontSize' | 'lineHeight' | 'letterSpacing'>;
const IOS_SCALE: Record<TextVariant, ScaleValue> = {
display: { fontSize: 43, lineHeight: 45, letterSpacing: 0.5 },
title1: { fontSize: 23, lineHeight: 29, letterSpacing: 0 },
title2: { fontSize: 20, lineHeight: 26, letterSpacing: 0 },
title3: { fontSize: 19, lineHeight: 27, letterSpacing: 0 },
subhead1: { fontSize: 18, lineHeight: 25, letterSpacing: 0.3 },
subhead2: { fontSize: 16, lineHeight: 22, letterSpacing: 0.2 },
body1: { fontSize: 17, lineHeight: 23, letterSpacing: 0.2 },
body2: { fontSize: 15, lineHeight: 19, letterSpacing: 0.2 },
label1: { fontSize: 14, lineHeight: 20, letterSpacing: 0.1 },
label2: { fontSize: 13, lineHeight: 15, letterSpacing: 0.5 },
caption: { fontSize: 12, lineHeight: 16, letterSpacing: 0.2 },
micro: { fontSize: 10, lineHeight: 13, letterSpacing: 0.5 },
};
const ANDROID_SCALE: Record<TextVariant, ScaleValue> = {
display: { fontSize: 39, lineHeight: 41, letterSpacing: 0.5 },
title1: { fontSize: 21, lineHeight: 27, letterSpacing: 0 },
title2: { fontSize: 18, lineHeight: 24, letterSpacing: 0 },
title3: { fontSize: 17, lineHeight: 25, letterSpacing: 0 },
subhead1: { fontSize: 16, lineHeight: 23, letterSpacing: 0.3 },
subhead2: { fontSize: 15, lineHeight: 20, letterSpacing: 0.2 },
body1: { fontSize: 16, lineHeight: 21, letterSpacing: 0.2 },
body2: { fontSize: 14, lineHeight: 17, letterSpacing: 0.2 },
label1: { fontSize: 13, lineHeight: 18, letterSpacing: 0.1 },
label2: { fontSize: 12, lineHeight: 14, letterSpacing: 0.5 },
caption: { fontSize: 11, lineHeight: 14, letterSpacing: 0.2 },
micro: { fontSize: 9, lineHeight: 12, letterSpacing: 0.5 },
};
const SCALE = Platform.select({ android: ANDROID_SCALE, default: IOS_SCALE });
// ─── Component ────────────────────────────────────────────────────────────────
export const Text = ({
children,
variant,
color = '#000000',
align = 'left',
numberOfLines,
rupee = false,
style,
accessible = true,
accessibilityLabel,
accessibilityRole,
testID,
}: Props) => (
<RNText
style={[
styles.base,
{ fontFamily: rupee ? FONT_RUPEE : FONT },
SCALE[variant],
{ color, textAlign: align },
style,
]}
numberOfLines={numberOfLines}
accessible={accessible}
accessibilityLabel={accessibilityLabel}
accessibilityRole={accessibilityRole}
testID={testID}
>
{children}
</RNText>
);
const styles = StyleSheet.create({
base: {
fontFamily: FONT,
color: '#000000',
},
});// Before
<Typography type="title-800" style={{ color: themeColors.Text_highContrast }}>
Where to?
</Typography>
// After
<Text variant="title1" color={themeColors.Text_highContrast}>
Where to?
</Text>// Before
<Typography
type="body-7"
style={{ color: themeColors.Text_neutralMidHigh, fontSize: 12 }}
>
3.2 km · 12 min
</Typography>
// After — fontSize 12 = caption
<Text variant="caption" color={themeColors.Text_neutralMidHigh}>
3.2 km · 12 min
</Text>// Before
<Typography type="title-800-rupee" style={{ color: themeColors.Text_highContrast }}>
₹249
</Typography>
// After
<Text variant="title1" rupee color={themeColors.Text_highContrast}>
₹249
</Text>// Before
<Text style={{ fontSize: 12, fontFamily: 'TimelessSans-GroteskSemibold', color: '#767676' }}>
AC
</Text>
// After
<Text variant="label2" color="#767676">
AC
</Text>import Animated from 'react-native-reanimated';
import { Text } from '@/src-v2/primitives/Text';
const AnimatedText = Animated.createAnimatedComponent(Text);
// Use AnimatedText exactly like Text, plus animatedPropsOld type="…" |
New variant="…" |
Notes |
|---|---|---|
title-800 |
title1 |
Direct swap |
title-2, title-4 |
title1 |
Only 1 use each — verify visually |
subhead-900 |
title1 |
21px → 23px, slightly larger. 1 use |
subhead-800, subhead-700, subhead-600 |
title3 |
Line height unifies to 27 |
title-3, subhead-4 |
title3 |
Direct swap |
callout, callout-1, callout-2 |
subhead1 |
Direct swap |
subhead, subhead-1 |
subhead1 |
Direct swap |
body-2 |
subhead1 |
18px → same size, slight lh change (23→25) |
subhead-2 |
subhead2 |
Direct swap |
body-3 |
subhead2 |
16px, tight lh — verify 6 callsites |
body, body-1, body-6 |
body1 |
Direct swap |
body-subtext |
body1 |
Direct swap |
body-8 |
title3 or body1 |
19px — check 4 callsites for intent |
body-7, sub-body-700, sub-body-800, sub-body-500 |
body2 |
Direct swap |
subhead-3 |
body2 |
15px, slightly tighter lh (17→19) |
body-5 |
label2 |
13px — direct swap |
micro |
label2 |
13px — direct swap |
body-4 |
display |
43px — verify it's still used |
title-800-rupee |
title1 + rupee prop |
|
subhead-1-rupee |
subhead2 + rupee prop |
16px matches subhead2 |
Inline overrides to migrate:
Inline fontSize |
Use variant |
|---|---|
| 20, 21 | title2 |
| 18, 19 | title3 or subhead1 — check context |
| 16, 17 | subhead2 or body1 |
| 14, 15 | label1 or body2 |
| 12, 13 | caption or label2 |
| 10, 11 | micro |
These sizes fall outside the 12-variant scale. Every one of them is a hardcoded fontSize with no token. Listed here so design can decide: add to the scale, round to the nearest variant, or keep as a deliberate one-off.
| File | Context |
|---|---|
src/typescript/screens/SafetyModal.tsx |
Safety modal heading |
src/typescript/screens/safetyCenter/UI.tsx (×3) |
Safety center section headings |
src/typescript/screens/safetyCenter/safetySettings/components/TrustedContactsList.tsx |
Contacts list heading |
src/typescript/screens/safetyCenter/safetySettings/components/ManualContactModal.tsx |
Manual contact modal heading |
src/typescript/screens/safetyCenter/safetySettings/components/TrustedContactsHelp.tsx |
Help screen heading |
src/typescript/screens/safety/UI.tsx (×2) |
Safety screen headings |
src/typescript/components/common/OTPComponentNew.tsx (×2) |
OTP digit display |
src/typescript/components/common/OTPComponentNew1.tsx (×2) |
OTP digit display |
src/typescript/components/PromotionalModal.tsx |
Promo modal heading |
src/typescript/components/ny-service/GenericVideoBottomSheet.tsx |
Bottom sheet heading |
src/FallbackUI.tsx |
Error fallback heading |
src-v2/screens/BusinessProfile/UI.tsx (×4) |
Business profile headings |
src-v2/screens/MetroIssueFaq/components/RaiseTicketModal.tsx |
Ticket modal heading |
src-v2/screens/ReportIssueChatScreen/UI.tsx |
Chat screen heading |
src-v2/screens/JourneySimulation/index.tsx (×3) |
Journey step headings |
src-v2/screens/Ticketing/PaymentStatusScreen/components/PaymentSucessView.tsx |
Payment success heading |
src-v2/screens/Ticketing/PaymentStatusScreen/components/PaymentPendingView.tsx |
Payment pending heading |
src-v2/screens/Ticketing/PaymentStatusScreen/components/PaymentFailedView.tsx |
Payment failed heading |
src-v2/screens/Ticketing/MyTicketScreen/UI.tsx |
Ticket screen heading |
src-v2/screens/Ticketing/EventDetailsScreen/UI.tsx |
Event detail heading |
src-v2/screens/reviewAndFeedback/variants/lynx/components/FeedbackSheet.tsx |
Feedback sheet heading |
src-v2/multimodal/screens/BusOtpFlow/OtpComponent.tsx (×2) |
Bus OTP digit display |
src-v2/components/PickupInstructionsModal/AudioRecordingModal.tsx (×4) |
Recording modal |
src-v2/components/PickupInstructionsModal/UI.tsx |
Pickup instructions heading |
Designer call: 24px is one point above
title1(23px). Add it as a named variant (e.g.headingat 24px), or round all uses down totitle1?
| File | Context |
|---|---|
src/typescript/screens/CallScreen.tsx:318 |
Caller name display |
src-v2/screens/ProfileTab/UI.tsx:328 |
Profile tab prominent label |
src-v2/screens/reviewAndFeedback/variants/lynx/components/RideSummaryHeader.tsx:114 |
Ride summary header |
src-v2/screens/Invoice/components/InvoiceCard.tsx:80 |
Invoice amount (rupee, lineHeight: 27) |
Designer call: Round to
title1(23px) or add a variant?
| File | Context |
|---|---|
src-v2/multimodal/screens/BusOtpFlow/components/Keypad.tsx:28 |
Numeric keypad digit |
Designer call: Intentional keypad size — likely keep as one-off.
| File | Context |
|---|---|
src/typescript/screens/CallScreen.tsx:434 |
Call screen secondary display |
src/typescript/components/ComingSoonModal.tsx:93 |
"Coming soon" modal heading |
src-v2/screens/JourneySimulation/index.tsx:545, 665 |
Journey step value display |
src-v2/components/PickupInstructionsModal/UI.tsx:677 |
Pickup instructions heading |
Designer call: Round to
title1(23px), or keep as one-off for modal hero moments?
| File | Context |
|---|---|
src-v2/screens/Referral/UI.tsx:399 |
Referral reward display |
Designer call: Keep as one-off (referral hero number).
| File | Context |
|---|---|
src/typescript/designSystem/components/RevisedFareCard.tsx:77 |
Fare card amount |
src/typescript/components/common/AnimatedModal.tsx:261 |
Animated modal heading |
src-v2/components/TippingModal/UI.tsx:250 |
Tip amount display |
Designer call: These are all hero numbers / amounts. Round to
display(43px) seems too big — add a mid-display variant, or keep as one-offs?
| File | Context |
|---|---|
src-v2/screens/JourneySimulation/index.tsx:590 |
Journey simulation value |
Designer call: Keep as one-off.
| File | Context |
|---|---|
src-v2/screens/RecentChatDetail/UI.tsx:332, 347 |
Emoji / icon character display |
src-v2/screens/BusinessProfile/UI.tsx:998 |
Business profile avatar/initial |
Designer call: These are likely emoji or avatar initials, not text. Keep as one-offs.
| File | Context |
|---|---|
src/typescript/screens/CallScreen.tsx:313 |
Call screen avatar / icon |
src-v2/screens/BusinessProfile/UI.tsx:1229, 1233 |
Business profile large display |
Designer call: Keep as one-offs — these are icon/avatar sized, not body text.
| Token | Reason |
|---|---|
callout-2 |
0 uses |
body-4 |
0 uses (confirm, then delete) |
subhead-4 |
0 uses |
title-2, title-4 |
1 use each — absorb into title1 |
subhead-900 |
1 use — absorb into title1 |
All of src/typescript/constants/style.tsx text styles |
Legacy PlusJakartaSans — retire after migrating old screens |
- Create
consumer/src-v2/primitives/Text.tsx— the new component - Update
consumer/src-v2/primitives/index.ts— exportTextandTextVariant - Migrate high-traffic screens first:
src-v2/screens/MyBookingDetails/components/BookingRideDetails.tsx(100+ uses)src-v2/screens/Preferences/PreferenceDetailUI.tsxsrc-v2/screens/LookingForRides/UI.tsxsrc-v2/primitives/Dropdown.tsxsrc-v2/primitives/Snackbar.tsx
- Keep
src/typescript/designSystem/components/primitives/Typography.tsxalive until allsrc/typescript/screens are migrated - Delete old tokens and Typography once 0 references remain
| File | Purpose |
|---|---|
src/typescript/designSystem/tokens/nammaYatriTokens.ts |
iOS token definitions (to retire) |
src/typescript/designSystem/tokens/nammaYatriTokensAndroid.ts |
Android token definitions (to retire) |
src/typescript/designSystem/components/primitives/Typography.tsx |
Current <Typography> (to retire) |
src/typescript/tailwindTheme/componentUtils/primitives/typography.ts |
Tailwind utils (to retire) |
src/typescript/constants/style.tsx |
Legacy PlusJakartaSans styles (to retire) |