Структура проєкту та перші UI-примітиви
Структура проєкту та перші UI-примітиви
Навіщо ця стаття
У попередньому матеріалі проєкт запускається: є Nomad, Metro, екран у Expo Go. Шаблон create-expo-app уже містить файли — інколи в корені, інколи з папкою app/ для навігації. Якщо лишити все «як вийшло з генератора» і далі кидати нові екрани в одну купу, через кілька тижнів з’являться типові проблеми:
- незрозуміло, де лежить логіка поїздок, а де — кнопки загального вигляду;
- кольори й відступи скопичені в десятках файлів (
#3B82F6,padding: 16усюди); - той самий стиль кнопки скопійовано тричі з дрібними відмінностями;
- імпорти виглядають як
../../../components/Button.
Мета цієї статті — закласти просту, розжовану структуру, якої вистачить на весь курс Nomad, без надмірної «ентерпрайз-архітектури» в перший тиждень.
app/ відповідає за маршрути (які екрани є в застосунку). Папка src/ — за зміст (фічі, спільний UI, тема). Екран у app/ має бути тонким: зібрати готові шматки з src/, а не містити всю бізнес-логіку в одному файлі на 800 рядків.Після статті:
- У Nomad з’явиться узгоджена структура папок.
- З’являться design tokens (словник кольорів, відступів, шрифтів).
- З’являться примітиви
Screen,AppText,Button. - Буде зрозуміло, навіщо файли
.ios.tsx/.android.tsx. - Міні-проєкт «Візитка» закріпить ідею окремо від Nomad.
- Git-коміт:
feat: design tokens and base UI primitives.
Глибока навігація (таби, стеки, deep links) — у модулі навігації. Тут Router згадується лише настільки, щоб зрозуміти роль app/.
::react-native-preview: у рамці телефону рендериться react-native-web (не справжній симулятор iOS/Android). Імпорти з @/…, Expo Router і пакети expo-* у прев’ю не працюють — тому демо зібрані самодостатнім TSX (токени «вшиті» в файл). У реальному Nomad ви пишете той самий UI через tokens і @/shared/ui.Дві моделі розкладки (і що обирає курс)
За шарами (layer-based)
components/
screens/
hooks/
services/
Усе «кнопкове» в одному місці, усі екрани — в іншому. На старті просто. Коли фіч багато, папка components/ перетворюється на звалище з сотнею файлів без зв’язку «що до чого належить».
За фічами (feature-based)
features/
trips/
ui/
model/
auth/
ui/
model/
shared/
ui/
theme/
Код поїздок лежить поруч (екранні шматки, типи, локальна логіка). Спільне (кнопка, тема) — у shared/.
Курс: гібрид, який добре лягає на Expo Router:
app/— лише маршрути (тонка оболонка);src/features/— код фіч;src/shared/— тема, UI-примітиви, утиліти.
Цільова структура Nomad
Точні імена файлів у шаблоні Expo можуть трохи відрізнятися. Нижче — ролі папок; повний код усього проєкту (знімок після цієї статті) — у розділі Nomad в кінці матеріалу (::code-tree з реальними файлами).
Репозиторій: https://github.com/arakviel/nomad.
app/, src/shared/, src/features/. Копіювати код зручніше з розділу Nomad: там повний знімок, не «порожні» stubs.app/index.tsx — початковий екран. app/_layout.tsx — спільна обгортка (провайдери, загальний стек). Детальні правила імен — у статті про Router; зараз не роздувайте дерево маршрутів.trips/ як заготовку — логіку поїздок додасте пізніше.#2563eb щоразу — беруть tokens.colors.primary.Псевдоніми імпортів (@/…)
Щоб не писати ../../../../shared/ui/Button, у TypeScript налаштовують path alias (псевдонім шляху).
Відкрийте tsconfig.json у Nomad. Зазвичай він уже має "extends": "expo/tsconfig.base". Додайте (або узгодьте) шляхи до src:
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}
Тоді імпорт виглядає так:
import { Button } from '@/shared/ui/Button';
import { tokens } from '@/shared/theme';
Зберегти tsconfig.json
Перезапустити TypeScript-сервер у VS Code
Command Palette → TypeScript: Restart TS Server (інколи достатньо закрити/відкрити файл).
Перезапустити Metro за потреби
Якщо збирач «не бачить» аліас: зупинити expo start і знову npx expo start. У сучасних шаблонах Expo аліаси з tsconfig зазвичай підхоплюються; якщо ні — перевірте документацію вашої версії SDK щодо experiments.tsconfigPaths / налаштувань Metro.
@/* → ./src/* означає, що файли всередині app/ імпортують з @/…, але самі лежать не під src/. Це нормально: маршрути лишаються в app/ за правилами Expo Router.Design tokens — словник вигляду
Навіщо
Уявіть, що «основний синій» записаний у двадцяти файлах. Дизайнер просить зробити бренд трохи темнішим — доведеться шукати двадцять місць і помилитися в половині.
Design tokens (токени дизайну) — іменовані значення: колір, відступ, розмір шрифту, радіус скруглення. Змінили токен — оновився весь UI, який на нього посилається.
Файл токенів
Створіть src/shared/theme/tokens.ts:
export const tokens = {
colors: {
background: '#F8FAFC',
surface: '#FFFFFF',
text: '#0F172A',
textSecondary: '#64748B',
primary: '#2563EB',
primaryPressed: '#1D4ED8',
border: '#E2E8F0',
danger: '#DC2626',
onPrimary: '#FFFFFF',
},
spacing: {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
},
radius: {
sm: 8,
md: 12,
lg: 16,
},
fontSize: {
sm: 14,
md: 16,
lg: 20,
xl: 28,
},
} as const;
export type Tokens = typeof tokens;
primary (головна дія), text (основний текст), background (фон екрана). Ролі легше міняти під темну тему пізніше.13 і 17. Око сприймає ритм спокійніше.Експорт з src/shared/theme/index.ts:
export { tokens } from './tokens';
export type { Tokens } from './tokens';
Примітив Screen — рамка екрана
Майже кожен екран потребує:
- безпечних відступів від «чубчика» й домашньої смужки (пізніше поглибимо Safe Area);
- фонового кольору;
- горизонтальних полів.
Поки зробимо просту обгортку на View + padding з токенів. Safe Area підключимо свідомо в UI-модулі; зараз головне — звичка не дублювати фон у кожному файлі.
src/shared/ui/Screen.tsx:
import { ReactNode } from 'react';
import { StyleSheet, View, ViewStyle } from 'react-native';
import { tokens } from '@/shared/theme';
type ScreenProps = {
children: ReactNode;
style?: ViewStyle;
};
export function Screen({ children, style }: ScreenProps) {
return <View style={[styles.root, style]}>{children}</View>;
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: tokens.colors.background,
paddingHorizontal: tokens.spacing.md,
paddingVertical: tokens.spacing.lg,
},
});
Як це виглядає на екрані
Нижче — ідея Screen: фон, горизонтальні поля, flex: 1. У прев’ю токени записані літералами (у проєкті — tokens.colors.background тощо).
Loading…
react-native-web · not a real device
import { View, Text, StyleSheet, useColorScheme } from 'react-native';
const tokens = {
colors: {
background: '#F8FAFC',
text: '#0F172A',
textSecondary: '#64748B',
border: '#E2E8F0',
surface: '#FFFFFF',
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24 },
radius: { md: 12 },
};
function Screen({ children }: { children: any }) {
return <View style={styles.screen}>{children}</View>;
}
export default function App() {
const scheme = useColorScheme();
const isDark = scheme === 'dark';
return (
<Screen>
<View
style={[
styles.panel,
{
backgroundColor: isDark ? '#1c1c1e' : tokens.colors.surface,
borderColor: isDark ? '#3a3a3c' : tokens.colors.border,
},
]}
>
<Text style={[styles.title, { color: isDark ? '#f5f5f7' : tokens.colors.text }]}>
Screen
</Text>
<Text style={[styles.body, { color: isDark ? '#a1a1aa' : tokens.colors.textSecondary }]}>
Фон, padding і flex: 1 — одна обгортка на всі екрани. Вміст (заголовки, кнопки, списки)
передається як children.
</Text>
</View>
</Screen>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: tokens.colors.background,
paddingHorizontal: tokens.spacing.md,
paddingVertical: tokens.spacing.lg,
},
panel: {
borderRadius: tokens.radius.md,
borderWidth: 1,
padding: tokens.spacing.md,
gap: tokens.spacing.sm,
},
title: {
fontSize: 22,
fontWeight: '700',
},
body: {
fontSize: 15,
lineHeight: 22,
},
});
Примітив AppText — текст з правилами
У React Native не можна покласти рядок просто в View, як у div у вебі — потрібен компонент Text. Якщо в кожному місці писати свій fontSize і колір, ритм типографіки роз’їдеться.
src/shared/ui/AppText.tsx:
import { ReactNode } from 'react';
import { StyleSheet, Text, TextProps, TextStyle } from 'react-native';
import { tokens } from '@/shared/theme';
type AppTextVariant = 'body' | 'title' | 'subtitle' | 'caption';
type AppTextProps = TextProps & {
children: ReactNode;
variant?: AppTextVariant;
color?: string;
style?: TextStyle;
};
export function AppText({
children,
variant = 'body',
color = tokens.colors.text,
style,
...rest
}: AppTextProps) {
return (
<Text style={[styles.base, styles[variant], { color }, style]} {...rest}>
{children}
</Text>
);
}
const styles = StyleSheet.create({
base: {
color: tokens.colors.text,
},
body: {
fontSize: tokens.fontSize.md,
lineHeight: 24,
},
title: {
fontSize: tokens.fontSize.xl,
fontWeight: '700',
lineHeight: 34,
},
subtitle: {
fontSize: tokens.fontSize.lg,
fontWeight: '600',
lineHeight: 28,
},
caption: {
fontSize: tokens.fontSize.sm,
color: tokens.colors.textSecondary,
lineHeight: 20,
},
});
AppText, а не Text, щоб не плутати з імпортом з react-native і одразу бачити в коді: «це наш узгоджений текст».Варіанти типографіки вживу
Чотири ролі поруч: title, subtitle, body, caption. У проєкті це один компонент AppText з пропом variant.
Loading…
react-native-web · not a real device
import { View, Text, StyleSheet, useColorScheme } from 'react-native';
const tokens = {
colors: {
background: '#F8FAFC',
text: '#0F172A',
textSecondary: '#64748B',
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24 },
fontSize: { sm: 14, md: 16, lg: 20, xl: 28 },
};
type Variant = 'title' | 'subtitle' | 'body' | 'caption';
function AppText({
children,
variant = 'body',
color,
}: {
children: any;
variant?: Variant;
color?: string;
}) {
return (
<Text style={[styles.base, styles[variant], color ? { color } : null]}>{children}</Text>
);
}
export default function App() {
const scheme = useColorScheme();
const isDark = scheme === 'dark';
const text = isDark ? '#f5f5f7' : tokens.colors.text;
const muted = isDark ? '#a1a1aa' : tokens.colors.textSecondary;
return (
<View style={[styles.screen, isDark && { backgroundColor: '#000' }]}>
<AppText variant="title" color={text}>
Мандрівник
</AppText>
<AppText variant="subtitle" color={text}>
Карпати на вихідні
</AppText>
<AppText variant="body" color={text}>
Основний абзац екрана: опис поїздки, інструкції, підказки. Розмір і міжрядковий інтервал
узгоджені з tokens.
</AppText>
<AppText variant="caption" color={muted}>
12–14 бер. 2026 · підпис / метадані
</AppText>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: tokens.colors.background,
padding: tokens.spacing.lg,
gap: tokens.spacing.sm,
justifyContent: 'center',
},
base: {
color: tokens.colors.text,
},
title: {
fontSize: tokens.fontSize.xl,
fontWeight: '700',
lineHeight: 34,
},
subtitle: {
fontSize: tokens.fontSize.lg,
fontWeight: '600',
lineHeight: 28,
},
body: {
fontSize: tokens.fontSize.md,
lineHeight: 24,
marginTop: tokens.spacing.sm,
},
caption: {
fontSize: tokens.fontSize.sm,
lineHeight: 20,
marginTop: tokens.spacing.xs,
},
});
Примітив Button — головна дія
Кнопка — місце, де найчастіше роз’їжджаються відступи й кольори. Зробимо простий варіант на Pressable (сучасніший за старі TouchableOpacity для нових екранів; деталі жестів — пізніше).
src/shared/ui/Button.tsx:
import { Pressable, StyleSheet, Text, ViewStyle } from 'react-native';
import { tokens } from '@/shared/theme';
type ButtonProps = {
label: string;
onPress: () => void;
disabled?: boolean;
style?: ViewStyle;
};
export function Button({ label, onPress, disabled = false, style }: ButtonProps) {
return (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.base,
pressed && !disabled ? styles.pressed : null,
disabled ? styles.disabled : null,
style,
]}
>
<Text style={styles.label}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
base: {
backgroundColor: tokens.colors.primary,
paddingVertical: tokens.spacing.sm + 4,
paddingHorizontal: tokens.spacing.md,
borderRadius: tokens.radius.md,
alignItems: 'center',
},
pressed: {
backgroundColor: tokens.colors.primaryPressed,
},
disabled: {
opacity: 0.5,
},
label: {
color: tokens.colors.onPrimary,
fontSize: tokens.fontSize.md,
fontWeight: '600',
},
});
style отримує, чи палець зараз натиснутий — можна змінити колір без окремого state.За бажанням src/shared/ui/index.ts:
export { AppText } from './AppText';
export { Button } from './Button';
export { Screen } from './Screen';
Кнопка вживу: primary, pressed, disabled
Натисніть «Нова поїздка» — лічильник і колір покажуть pressed. Друга кнопка навмисно disabled.
Loading…
react-native-web · not a real device
import { useState } from 'react';
import { View, Text, Pressable, StyleSheet, useColorScheme } from 'react-native';
const tokens = {
colors: {
background: '#F8FAFC',
text: '#0F172A',
textSecondary: '#64748B',
primary: '#2563EB',
primaryPressed: '#1D4ED8',
onPrimary: '#FFFFFF',
},
spacing: { sm: 8, md: 16, lg: 24 },
radius: { md: 12 },
fontSize: { md: 16 },
};
function Button({
label,
onPress,
disabled = false,
}: {
label: string;
onPress: () => void;
disabled?: boolean;
}) {
return (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.base,
pressed && !disabled ? styles.pressed : null,
disabled ? styles.disabled : null,
]}
>
<Text style={styles.label}>{label}</Text>
</Pressable>
);
}
export default function App() {
const [taps, setTaps] = useState(0);
const scheme = useColorScheme();
const isDark = scheme === 'dark';
return (
<View style={[styles.screen, isDark && { backgroundColor: '#000' }]}>
<Text style={[styles.heading, { color: isDark ? '#f5f5f7' : tokens.colors.text }]}>
Натискань: {taps}
</Text>
<Text style={[styles.hint, { color: isDark ? '#a1a1aa' : tokens.colors.textSecondary }]}>
style як функція від {'{ pressed }'} — без окремого useState для «втоплення».
</Text>
<Button label="Нова поїздка" onPress={() => setTaps((n) => n + 1)} />
<Button label="Недоступно" onPress={() => {}} disabled />
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: tokens.colors.background,
padding: tokens.spacing.lg,
justifyContent: 'center',
gap: tokens.spacing.md,
},
heading: {
fontSize: 28,
fontWeight: '700',
},
hint: {
fontSize: 14,
lineHeight: 20,
marginBottom: tokens.spacing.sm,
},
base: {
backgroundColor: tokens.colors.primary,
paddingVertical: tokens.spacing.sm + 4,
paddingHorizontal: tokens.spacing.md,
borderRadius: tokens.radius.md,
alignItems: 'center',
},
pressed: {
backgroundColor: tokens.colors.primaryPressed,
},
disabled: {
opacity: 0.5,
},
label: {
color: tokens.colors.onPrimary,
fontSize: tokens.fontSize.md,
fontWeight: '600',
},
});
Зібрати домашній екран Nomad
Оновіть app/index.tsx (шлях може бути app/(tabs)/index.tsx у вашому шаблоні — важливий домашній екран). Мета: тонкий маршрут + примітиви + українські рядки.
import { StyleSheet, View } from 'react-native';
import { tokens } from '@/shared/theme';
import { AppText, Button, Screen } from '@/shared/ui';
export default function HomeScreen() {
return (
<Screen>
<View style={styles.header}>
<AppText variant="title">Мандрівник</AppText>
<AppText variant="caption" style={styles.caption}>
Щоденник подорожей
</AppText>
</View>
<AppText style={styles.body}>
Тут згодом зʼявиться список поїздок. Зараз закладаємо структуру проєкту та спільні
компоненти.
</AppText>
<Button
label="Нова поїздка"
onPress={() => {
// Навігацію на форму додамо в модулі Router
console.log('TODO: відкрити створення поїздки');
}}
/>
</Screen>
);
}
const styles = StyleSheet.create({
header: {
marginBottom: tokens.spacing.lg,
gap: tokens.spacing.xs,
},
caption: {
marginTop: tokens.spacing.xs,
},
body: {
marginBottom: tokens.spacing.lg,
},
});
Зберегти файли theme та ui
Переконатися, що @/ резолвиться
Запустити npx expo start у Nomad
Перевірити екран
Має бути заголовок «Мандрівник», підзаголовок, абзац і синя кнопка. Натискання пише в терминал Metro TODO: ….
Прев’ю домашнього екрана (самодостатній демо-файл)
У Nomad імпорти йдуть з @/shared/ui. Нижче — той самий вигляд, зібраний у одному файлі, щоб побачити результат без запуску Metro.
Loading…
react-native-web · not a real device
import { useState } from 'react';
import { View, Text, Pressable, StyleSheet, useColorScheme } from 'react-native';
const tokens = {
colors: {
background: '#F8FAFC',
text: '#0F172A',
textSecondary: '#64748B',
primary: '#2563EB',
primaryPressed: '#1D4ED8',
onPrimary: '#FFFFFF',
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24 },
radius: { md: 12 },
fontSize: { sm: 14, md: 16, xl: 28 },
};
function Screen({ children }: { children: any }) {
return <View style={styles.screen}>{children}</View>;
}
function AppText({
children,
variant = 'body',
style,
}: {
children: any;
variant?: 'title' | 'body' | 'caption';
style?: object;
}) {
return <Text style={[styles.textBase, styles[variant], style]}>{children}</Text>;
}
function Button({ label, onPress }: { label: string; onPress: () => void }) {
return (
<Pressable
accessibilityRole="button"
onPress={onPress}
style={({ pressed }) => [styles.btn, pressed && styles.btnPressed]}
>
<Text style={styles.btnLabel}>{label}</Text>
</Pressable>
);
}
export default function App() {
const [log, setLog] = useState('Очікуємо дію…');
const scheme = useColorScheme();
const isDark = scheme === 'dark';
const text = isDark ? '#f5f5f7' : tokens.colors.text;
const muted = isDark ? '#a1a1aa' : tokens.colors.textSecondary;
return (
<Screen>
<View style={styles.header}>
<AppText variant="title" style={{ color: text }}>
Мандрівник
</AppText>
<AppText variant="caption" style={{ color: muted }}>
Щоденник подорожей
</AppText>
</View>
<AppText variant="body" style={{ color: text, marginBottom: tokens.spacing.lg }}>
Тут згодом зʼявиться список поїздок. Зараз закладаємо структуру проєкту та спільні
компоненти.
</AppText>
<Button
label="Нова поїздка"
onPress={() => setLog('TODO: відкрити створення поїздки')}
/>
<Text style={[styles.log, { color: muted }]}>{log}</Text>
</Screen>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: tokens.colors.background,
paddingHorizontal: tokens.spacing.md,
paddingVertical: tokens.spacing.lg,
},
header: {
marginBottom: tokens.spacing.lg,
gap: tokens.spacing.xs,
},
textBase: {
color: tokens.colors.text,
},
title: {
fontSize: tokens.fontSize.xl,
fontWeight: '700',
lineHeight: 34,
},
body: {
fontSize: tokens.fontSize.md,
lineHeight: 24,
},
caption: {
fontSize: tokens.fontSize.sm,
lineHeight: 20,
},
btn: {
backgroundColor: tokens.colors.primary,
paddingVertical: 12,
paddingHorizontal: tokens.spacing.md,
borderRadius: tokens.radius.md,
alignItems: 'center',
},
btnPressed: {
backgroundColor: tokens.colors.primaryPressed,
},
btnLabel: {
color: tokens.colors.onPrimary,
fontSize: tokens.fontSize.md,
fontWeight: '600',
},
log: {
marginTop: tokens.spacing.md,
fontSize: 12,
fontFamily: 'monospace',
},
});
@/…, перевірте paths у tsconfig.json і що файли справді лежать під src/shared/..., а не під shared/ у корені за звичкою з іншого шаблону.Кореневий app/_layout.tsx поки можна лишити близьким до шаблону. Головне — не видалити обгортку Router. Якщо в layout є Stack/Tabs з шаблону — не ламайте навігацію; змінюєте насамперед вміст домашнього екрана.
Платформні відмінності: .ios.tsx і .android.tsx
Інколи один і той самий компонент на iPhone і Android має різну реалізацію (рідко на старті, частіше з нативними модулями). React Native уміє підставити файл за суфіксом:
| Файл | Коли береться |
|---|---|
Button.ios.tsx | збірка під iOS |
Button.android.tsx | збірка під Android |
Button.tsx | запасний / спільний варіант |
Button.native.tsx | будь-яка нативна платформа (не web) |
Імпорт лишається:
import { Button } from '@/shared/ui/Button';
Збирач сам обере суфікс.
.ios / .android файли не обов’язкові. Достатньо знати механізм, щоб не здивуватися в чужому репозиторії. Для Nomad поки один Button.tsx на обидві платформи.Також існує об’єкт Platform з react-native (Platform.OS === 'ios'). Дрібні відмінності (один відступ) часто роблять через Platform.select, а не через два файли. Порівняння підходів — у статті про базові компоненти.
Що не класти в app/
Великі форми й бізнес-логіка
src/features/trips/ui/TripForm.tsx, а в app/ — кілька рядків імпорту й рендеру.Токени й кнопки
src/shared/…. Інакше фіча «поїздки» потягне за собою чужі кольори.Секрети API
Міні-проєкт «від А до Я»: Візитка
Мета
Окремий маленький застосунок projects/business-card (не змішувати з Nomad), щоб закріпити:
- tokens;
Screen/AppText/Button;- два екрани-заглушки без глибокої навігації (два файли маршрутів або два простих екрани в Router).
Сценарій продукту
Екран 1 — візитка: ім’я, роль, коротка біо.
Екран 2 — контакти: email / телефон як текст (без реальної інтеграції).
Кнопка «Контакти» на першому екрані веде на другий (через router.push з expo-router — мінімум API; якщо Router лякає, зробіть два проєкти blank… але краще один проєкт з app/index.tsx і app/contacts.tsx).
Кроки
Створити проєкт
cd /path/to # поруч: git clone https://github.com/arakviel/nomad.git
npx create-expo-app@latest business-card
cd business-card
Додати src/shared/theme і src/shared/ui
Можна скопіювати ідею токенів і примітивів з Nomad (навмисно повторити руками — краще, ніж copy-paste без читання).
Налаштувати @/* → ./src/* у tsconfig
Зібрати app/index.tsx — візитка
Українською: ім’я, «Розробник інтерфейсів», кнопка «Контакти».
Додати app/contacts.tsx
Текст контактів + кнопка «Назад» (router.back() з expo-router).
Перевірити на пристрої / симуляторі
Обидва екрани відкриваються, стилі з токенів, без «сирих» #ff0000 у JSX.
Критерій «готово»
- Є
tokens.tsі щонайменше три примітиви. - На екранах немає дубльованих hex-кольорів «від руки» (крім самого файлу токенів).
- Два маршрути працюють.
- Проєкт лежить у
projects/business-card, Nomad не зламаний.
Орієнтир вигляду (візитка)
У реальному міні-проєкті буде два маршрути (index / contacts) і router.push. Тут — один екран-візитка з кнопкою, яка «перемикає» вміст (імітація двох екранів без Router у прев’ю).
Loading…
react-native-web · not a real device
import { useState } from 'react';
import { View, Text, Pressable, StyleSheet, useColorScheme } from 'react-native';
const tokens = {
colors: {
background: '#F8FAFC',
surface: '#FFFFFF',
text: '#0F172A',
textSecondary: '#64748B',
primary: '#2563EB',
primaryPressed: '#1D4ED8',
onPrimary: '#FFFFFF',
border: '#E2E8F0',
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24 },
radius: { md: 12, lg: 16 },
fontSize: { sm: 14, md: 16, lg: 20, xl: 28 },
};
function Button({ label, onPress }: { label: string; onPress: () => void }) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [styles.btn, pressed && styles.btnPressed]}
>
<Text style={styles.btnLabel}>{label}</Text>
</Pressable>
);
}
export default function App() {
const [page, setPage] = useState<'card' | 'contacts'>('card');
const scheme = useColorScheme();
const isDark = scheme === 'dark';
const text = isDark ? '#f5f5f7' : tokens.colors.text;
const muted = isDark ? '#a1a1aa' : tokens.colors.textSecondary;
const surface = isDark ? '#1c1c1e' : tokens.colors.surface;
const border = isDark ? '#3a3a3c' : tokens.colors.border;
return (
<View style={[styles.screen, isDark && { backgroundColor: '#000' }]}>
<View style={[styles.card, { backgroundColor: surface, borderColor: border }]}>
{page === 'card' ? (
<>
<View style={styles.avatar}>
<Text style={styles.avatarText}>ОМ</Text>
</View>
<Text style={[styles.name, { color: text }]}>Олена Мельник</Text>
<Text style={[styles.role, { color: muted }]}>Розробниця інтерфейсів</Text>
<Text style={[styles.bio, { color: text }]}>
Роблю мобільні та веб-екрани, люблю чіткі tokens і спокійні відступи.
</Text>
<Button label="Контакти" onPress={() => setPage('contacts')} />
</>
) : (
<>
<Text style={[styles.name, { color: text }]}>Контакти</Text>
<Text style={[styles.bio, { color: text }]}>email: olena@example.com</Text>
<Text style={[styles.bio, { color: text }]}>тел: +380 50 000 00 00</Text>
<Text style={[styles.role, { color: muted }]}>
У проєкті business-card це окремий маршрут app/contacts.tsx
</Text>
<Button label="Назад" onPress={() => setPage('card')} />
</>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: tokens.colors.background,
padding: tokens.spacing.lg,
justifyContent: 'center',
},
card: {
borderRadius: tokens.radius.lg,
borderWidth: 1,
padding: tokens.spacing.lg,
gap: tokens.spacing.sm,
alignItems: 'center',
},
avatar: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: tokens.colors.primary,
alignItems: 'center',
justifyContent: 'center',
marginBottom: tokens.spacing.sm,
},
avatarText: {
color: '#fff',
fontSize: 22,
fontWeight: '700',
},
name: {
fontSize: tokens.fontSize.xl,
fontWeight: '700',
textAlign: 'center',
},
role: {
fontSize: tokens.fontSize.sm,
textAlign: 'center',
},
bio: {
fontSize: tokens.fontSize.md,
lineHeight: 24,
textAlign: 'center',
marginVertical: tokens.spacing.sm,
},
btn: {
marginTop: tokens.spacing.sm,
alignSelf: 'stretch',
backgroundColor: tokens.colors.primary,
paddingVertical: 12,
borderRadius: tokens.radius.md,
alignItems: 'center',
},
btnPressed: {
backgroundColor: tokens.colors.primaryPressed,
},
btnLabel: {
color: tokens.colors.onPrimary,
fontSize: tokens.fontSize.md,
fontWeight: '600',
},
});
- Кольори кнопок збігаються з
tokens.colors.primary. - Заголовки через
AppText variant="title". Screenзадає фон і padding.- Імпорти через
@/shared/.... - Перехід index → contacts → back без падіння.
Nomad: структура, токени та UI-примітиви
Навіщо користувачу
Nomad — не «hello world назавжди». Уже на старті потрібна зрозуміла структура: куди класти тему, спільні кнопки, майбутні фічі. Інакше через три статті все звалиться в один App.tsx.
Нитка проєкту
Уже є (з попередніх статей — не викидаємо):
- Репозиторій створено (стаття 04): Expo + TypeScript +
app/маршрути.
Додаємо в цій статті:
src/shared/theme— design tokens.src/shared/ui—Screen,AppText,Button.- Псевдонім
@/*уtsconfig. - Домашній екран збирається з примітивів, не з «голого» JSX на всю сторінку.
Повний знімок проєкту після цієї статті
git pull у клоні репо. Нижче — весь проєкт на момент цієї статті (усі файли з кодом), не фрагмент «лише нові папки». Клік по файлу в дереві → повний вміст для копіпасту.cd /path/to/nomad
git pull
npm install
npx expo start
Перевірка
npx expo start— екран відкривається без помилок імпорту@/….- На екрані видно текст через
AppTextі кнопкуButton. - Файли лежать у
src/shared/…як у дереві нижче.
Коміт
Якщо збирали вручну (не через git pull на вже запушений репо):
cd /path/to/nomad
git add -A
git commit -m "$(cat <<'EOF'
feat: design tokens and base UI primitives
Material: content/15.react-native/05.project-structure-and-conventions.md
EOF
)"
git push
У публічному репо цей коміт уже є — після git pull повторно комітити не потрібно, якщо ви не змінювали код локально.
Підсумок
app/ — маршрути, тонкі екрани
src/features — код фіч; src/shared — тема й UI
Tokens — єдиний словник вигляду
Screen, AppText, Button — перші цеглини
@/ — короткі імпорти
Платформні суфікси — знати, не зловживати на старті
Візитка — окремий тренажер; Nomad — основний продукт
Далі — базові компоненти React Native (View, Text, Image, Pressable, ScrollView) уже з глибшим розбором API, на фундаменті структури з цієї статті.
Практичні завдання
Базовий рівень
- У Nomad створити tokens + три примітиви + український домашній екран.
- Пояснити своїми словами різницю між
app/іsrc/shared/ui/. - Замінити один «магічний» відступ у своєму коді на
tokens.spacing.*.
Середній рівень
- Виконати міні-проєкт Візитка.
- Додати в tokens ще одну роль кольору (наприклад
success) і використати її вAppTextабо другорядній кнопці. - Зробити
src/shared/ui/index.tsі імпортувати примітиви одним рядком.
Професійний рівень
- Коротко (пів сторінки) порівняти feature-based і layer-based для команди з 5 людей.
- Додати другий варіант кнопки
variant: 'primary' | 'secondary'без дублювання всього компонента. - Налаштувати
.vscode/settings.jsonу Nomad зtypescript.tsdkна проєктний TypeScript (з попередньої статті) і перевірити, що@/підсвічується без помилок.
Часті запитання
app/ + src/.Screen, текст, кнопка — мінімум. Input, Card, ListRow — коли дійдете до форм і списків.Середовище Expo — перший запуск
Покрокове налаштування Node, створення проєкту через create-expo-app, запуск Metro і перегляд застосунку на телефоні або симуляторі
Базові компоненти React Native
View, Text, Image, Pressable, ScrollView, Safe Area та Platform — будівельні блоки екранів і mock-картка поїздки в Nomad