React Native

StyleSheet і теми (light / dark)

StyleSheet.create, масиви стилів, семантичні токени кольорів, useColorScheme і перемикач світлої/темної теми в Nomad

StyleSheet і теми (light / dark)

Навіщо ця стаття

Уявіть вечір у метро: людина відкриває щоденник подорожей, у телефоні давно увімкнений темний режим (Dark Mode). Системні екрани й більшість улюблених застосунків уже темні: фон глибокий, текст світлий, статус-бар читабельний. Якщо ваш застосунок лишається сліпучо-білим, користувач не думає «ой, у них ще не зробили StyleSheet.create». Він думає: «цей додаток виглядає застаріло» — і закриває його.

У попередніх статтях стилі вже з’являлись: StyleSheet.create, tokens.colors.primary, інколи useColorScheme у демо. Але кольори в Nomad досі були одні — світла палітра «намертво» вшита в об’єкт tokens. Перемикач системи нічого не змінював на екрані, бо компоненти питали не «який зараз режим?», а «який hex записаний у файлі раз і назавжди?».

Ця стаття з’єднує дві речі, які на вебі часто плутають:

  1. Як описувати вигляд елемента в React Native (об’єкти стилів, StyleSheet, масиви) — аналог CSS, але інший механізм.
  2. Як міняти theme (тему) всього застосунку (світла / темна тема, семантичні імена кольорів) — аналог CSS-змінних і prefers-color-scheme, своїми інструментами.

Терміни простими словами

Стиль (style) у React Native — звичайний JavaScript-об’єкт (або зареєстрований через StyleSheet ідентифікатор), який каже нативному UI: «відступ 16, заокруглення 12, колір фону такий-то». Немає окремого файлу .css, немає селекторів .card > .title.

Тема (theme) — узгоджений набір значень (передусім кольорів) для режиму відображення. Найчастіше два режими: світлий (light) і темний (dark). Тема — не «красивий шаблон з Figma», а словник, з якого всі екрани беруть одні й ті самі ролі кольорів.

Семантичний токен (semantic token) — ім’я за роллю, а не за відтінком.
Приклад: surface означає «поверхня картки / панелі», а не «білий». У light surface може бути #FFFFFF, у dark — #1C1C1E. Компонент пише colors.surface і не знає, який саме hex підставлять сьогодні.

Схема кольорів (color scheme) — відповідь операційної системи (або вашого перемикача): зараз світлий чи темний інтерфейс. Хук useColorScheme читає системну схему.

Перевага користувача (preference) у нашому коді — що обрав людина в застосунку: «як у системі», «завжди світла», «завжди темна». Це вже не чиста ОС, а ваш стан (поки в пам’яті; збереження на диск — пізніше).

Після статті ви зможете:

  1. Пояснити, чим RN-стилі відрізняються від CSS і навіщо StyleSheet.create.
  2. Складати style={[…]} без сюрпризів із перекриттям.
  3. Відокремити геометрію (padding, radius) від кольорів теми.
  4. Зчитати системну тему і перевизначити її вибором користувача.
  5. Самі зібрати ThemeProvider + useTheme на React Context і підключити light/dark у Nomad.
Головна думка. Стилі описують форму (відступи, радіус, flex, розташування). Тема описує забарвлення ролей (фон екрана, поверхня картки, основний текст, акцент кнопки). Якщо в компоненті написано backgroundColor: '#fff', темна тема ніколи «сама» не з’явиться — рядок уже вирішив колір назавжди. Якщо написано backgroundColor: colors.surface, достатньо підмінити словник кольорів — компонент перемалюється з новою theme, без правок у десятках файлів.

Місток з web React / CSS

На вебі ви звикли до трьох шарів:

  1. Розмітка (JSX / HTML) — що є на сторінці.
  2. CSS — як воно виглядає (часто в іншому файлі або через Tailwind-класи).
  3. Тема — CSS variables, data-theme, prefers-color-scheme, інколи Context у React.

У React Native розмітка лишається JSX, але «CSS-файлу» немає. Замість нього — prop style на майже кожному візуальному компоненті. Значення — об’єкт (або масив об’єктів / зареєстрованих стилів). Під капотом RN перетворює це на властивості нативних view (UIView / android.view), а не на CSSOM браузера.

У браузеріУ React NativeЩо це означає для вас
CSS-файл, CSS Modules, Tailwindоб’єкти + StyleSheetНемає глобальних селекторів .card { }, немає «каскаду по дереву HTML» у класичному сенсі
className="card active"style={[styles.card, active && styles.active]}Стани вмикаєте в JSX умовами, не другим класом у рядку
CSS variables (--bg)токени + тема (Context / props)«Змінні» — ваш TypeScript-об’єкт, не :root
prefers-color-schemeuseColorScheme() / модуль AppearanceСистема повідомляє light/dark; підписка через хук
color-scheme на htmlStatusBar + кольори екранаСистемні іконки зверху не підлаштовуються самі лише від фону View

Що ламається, якщо мислити «як у CSS»

  • Немає !important, немає специфічності селекторів. Є лише порядок у масиві style={[a, b, c]} — хто правіше, той перемагає в конфлікті ключів.
  • Немає успадкування color / font-size на всі вкладені View як у HTML. Текст живе в Text; стилі тексту не «просочуються» в дочірні View.
  • Немає «підключив dark.css і все перефарбувалось». Поки компонент читає літерал #fff, ніякий Dark Mode ОС йому не допоможе.
Web-звичка. Шукати dark: як у Tailwind або :root[data-theme=dark] і чекати, що RN «зрозуміє». У нашому шляху (StyleSheet + токени, без NativeWind) темна тема — це ваші дві палітри + хук/контекст + дисципліна «не писати hex у компонентах». Це не гірше — просто явніше: ви бачите, звідки взявся колір.
Аналогія. CSS на сайті — як загальні правила будинку («усі двері коричневі»). Стилі RN — як наклейка на кожні двері окремо: «ця — 16 px padding, ось такий колір». Тема — як палітра фарб на складі: сьогодні видаємо світлі банки з етикеткою surface, завтра — темні з тією ж етикеткою. Майстер (компонент) просить «банку surface», а не «білу №12».

Як RN малює «вигляд»

Коротко, щоб не здавалось магією:

  1. Ви пишете <View style={…} />.
  2. React Native (разом із нативним шаром) застосовує ці властивості до платформенного прямокутника на екрані.
  3. Flexbox (попередня стаття) рахує розміри й позиції.
  4. Колір, бордер, тінь, прозорість — окремі властивості того ж стилю.

Тому «стилізація» у мобільному React — це не «підключити stylesheet до сторінки», а проп на конкретному елементі, плюс домовленість команди, звідки брати повторювані значення (токени, тема).


StyleSheet — навіщо цей модуль

StyleSheet — вбудований модуль React Native для роботи зі стилями. Найчастіший виклик — StyleSheet.create({ … }): ви передаєте об’єкт «ім’я → опис вигляду», отримуєте об’єкт з тими ж ключами для style={styles.card}.

Навіщо не писати все inline

Технічно так можна:

<View style={{ flex: 1, padding: 16, backgroundColor: '#F8FAFC' }} />

На маленькому демо це читабельно. У реальному екрані з’являються проблеми:

  • JSX роздувається: важко побачити структуру «шапка / список / кнопка» серед об’єктів.
  • Ті самі відступи й радіуси копіюють у п’ять місць — потім міняєте «16» на «12» і забуваєте одне.
  • Умови (pressed, disabled, error) перетворюють style={{…}} на важкий для читання вираз.

StyleSheet.create — домовленість «вигляд зібрано окремо, JSX описує склад». Це схоже на CSS Modules: імена локальні, але без окремого синтаксису CSS.

Що реально дає create на практиці

  1. Ясність — стилі внизу файлу (або в Foo.styles.ts); у JSX лишаються імена.
  2. Підказки TypeScript / dev — зайва властивість або неправильний тип частіше «світиться» раніше, ніж на пристрої.
  3. Передбачуваний патерн у команді — новий розробник шукає StyleSheet.create і одразу розуміє, де живе вигляд.
  4. Оптимізації з боку RN (історично: реєстрація стилів, менше «сирих» об’єктів на мості). Деталі змінювались між версіями; не будуйте архітектуру заради мікробенчмарків. Будуйте заради читабельності й єдиного місця правки.
Важливо.StyleSheet.createне робить стилі «реактивними до теми» саме по собі. Якщо всерединіcreate на рівні модуля ви написали backgroundColor: '#F8FAFC', це значення зафіксоване в момент завантаження модуля. Темна тема підключається інакше: або різні об’єкти стилів, або (краще) динамічний колір у render: [styles.card, { backgroundColor: colors.surface }].

API StyleSheet — що це і що видно на екрані

Нижче — коротко API, а одразу після — демо: спочатку по одному інструменту, потім усе разом.

StyleSheet.create(obj)
API
Приймає об’єкт { name: ViewStyle | TextStyle | ImageStyle }, повертає об’єкт з тими ж ключами.
На екрані: JSX лишається коротким (style={styles.card}), а «паспорт» вигляду — внизу файлу. Без create той самий UI виглядає так само, але код роздувається.
StyleSheet.flatten(style)
API
Зводить масив / вкладені стилі до одного плоского об’єкта ({ fontSize: 18, color: '…', … }).
На екрані: користувач нічого не «бачить» окремо — flatten для вас (тест, лог, дебаг). У звичайному JSX flatten робить сам RN.
Коли треба руками: «який підсумковий fontSize після трьох шарів?» → JSON.stringify(StyleSheet.flatten([…])).
StyleSheet.absoluteFill
preset
Готовий стиль: position: 'absolute' + top/left/right/bottom: 0 — розтягнути на всього батька.
На екрані: напівпрозорий шар поверх фото/картки («Завантаження…», «Недоступно»), затемнення під модалкою.
StyleSheet.absoluteFillObject
preset
Те саме як звичайний JS-об’єкт (його можна спредити).
На екрані: те саме, що absoluteFill, але ви пишете
{ ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 2 } — один об’єкт, без другого шару в масиві.
StyleSheet.hairlineWidth
number
Найтонша лінія, яку платформа ще малює акуратно (часто менше за 1 логічний піксель).
На екрані: розділювачі списку «як у Налаштуваннях iOS», а не жирна смуга borderWidth: 1. Порівняйте hairline vs 1 у демо нижче.

Демо 1: create + масив стилів

create тримає базу кнопки. Масив у Pressable додає шари: «увімкнено» і «натиснуто». Порядок зліва направо — пізніший ключ перемагає.

TSXStyleSheetCreateArray.tsx
iPhone
9:41

Loading…

react-native-web · not a real device

Демо 2: flatten — «що вийшло в сумі»

Два шари стилів заголовка + колір з теми. На екрані — текст і JSON підсумкового об’єкта після StyleSheet.flatten. У продакшені UI так рідко показують; тут — щоб побачити злиття ключів.

TSXStyleSheetFlatten.tsx
iPhone
9:41

Loading…

react-native-web · not a real device

Демо 3: absoluteFill vs absoluteFillObject

Обидві картки тягнуть шар на весь батько. Зліва — StyleSheet.absoluteFill + другий стиль у масиві. Справа — один об’єкт через ...absoluteFillObject (зручно додати backgroundColor / zIndex «на місці»).

TSXAbsoluteFillCompare.tsx
Android
9:41

Loading…

react-native-web · not a real device

Демо 4: hairlineWidth vs 1

Два рядки списку: зверху розділювач hairline, знизу — звичайна 1. На Retina/високій щільності hairline виглядає тоншою й «системнішою».

TSXHairlineDemo.tsx
iPhone
9:41

Loading…

react-native-web · not a real device

Демо 5: усе разом (create · flatten · absoluteFill · absoluteFillObject · hairlineWidth)

Один екран-«візитка»: геометрія з create, тонкі лінії hairline, підсумок flatten у підписі, кнопка відкриває оверлей через absoluteFill, бейдж «PRO» — через absoluteFillObject у куті.

TSXStyleSheetAllApi.tsx
iPhone
9:41

Loading…

react-native-web · not a real device

Inline vs StyleSheet: коли що

// Працює, але шумно, якщо так на кожному елементі
<View style={{ flex: 1, padding: 16, backgroundColor: '#F8FAFC' }} />

// Звичка для геометрії
<View style={styles.screen} />

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 16 },
});

Inline доречний, коли значення обчислюється зараз і не варте окремого імені:

// Колір з поточної теми — так і треба
<View style={[styles.card, { backgroundColor: colors.surface }]} />

Тут styles.card тримає стабільне: borderRadius, borderWidth, overflow.
colors.surface — з теми, може змінитись між light і dark без нового StyleSheet.create.

Масив стилів — «каскад у мініатюрі»

У CSS кілька класів і специфічність. У RN ви явно складаєте шари:

style={[
  styles.base,              // 1. база
  pressed && styles.pressed, // 2. стан жесту
  disabled && styles.disabled, // 3. стан «вимкнено»
  styleFromProps,           // 4. те, що передав батько (часто перемагає)
]}

Порядок: зліва направо. Якщо і base, і pressed задають backgroundColor, перемагає пізніший.

false / null / undefined у масиві ігноруються. Тому cond && styles.x безпечний: коли cond хибний, у масив потрапляє false, і RN його відкидає.

Що бачить користувач. Натискає кнопку — на мить темнішає (pressed). Це не окремий «CSS файл», а той самийPressable, який у функціональному style={({ pressed }) => […]} підмішує другий шар. Усі стани видно в коді поруч.

Типова помилка: create всередині компонента на кожен render

function Card() {
  const styles = StyleSheet.create({ box: { padding: 16 } }); // погана звичка
  return <View style={styles.box} />;
}

На кожному рендері створюється новий набір. Для теми це не вирішує light/dark. Виносьте стабільну геометрію на рівень модуля; динаміку — у масив у JSX.

Платформа: тіні й hairline

Уже бачили Platform.select для тіні (iOS shadow*, Android elevation). Це теж частина стилізації, не теми. Тема може змінити shadowOpacity у dark (тінь на чорному майже невидима) — але спочатку відокремте: «як малюємо тінь» vs «які кольори ролей».


Семантичні токени (не «синій», а «primary»)

Звідки взялась ідея

У статті 05 з’явився файл tokens — словник відступів, радіусів, кольорів. Тоді мета була простіша: не розмножувати #2563EB у десяти кнопках. Тепер додаємо другий вимір: той самий словник має вміти дві themes (light і dark), не ламаючи компоненти.

На вебі в дизайн-системах (і в Figma) розрізняють рівні приблизно так:

  1. Примітивні значення: blue-600 = #2563EB, gray-50 = #F8FAFC — «як у віялі фарб».
  2. Семантичні ролі: color.bg.canvas, color.fg.default, color.action.primary — «для чого ця фарба на екрані».

У малому застосунку ми не будуємо повну трирівневу систему з сотнею токенів. Але правило одне: у JSX компонентів живуть ролі, а hex — лише в таблицях light/dark.

Погано vs добре

Погано (прив’язка до відтінку)Краще (роль)Чому
blue500 у TripCardprimaryБренд може змінити синій — роль «головна дія» лишається
#fff у двадцяти файлахcolors.surfaceDark не білий; одне місце правки
gray900 для текстуcolors.textУ dark текст світлий, не «сірий-900»
«темна тема = інвертувати все»друга таблиця з тими ж ключамиФото, карта, логотип бренду не інвертують

Популярні схеми імен (як називають «у світі»)

Різні дизайн-системи кажуть те саме різними словами. Важливо не «вгадати священну назву», а узгодити словник у команді й тримати однакові ключі в light і dark.

Роль змістомПлоскі імена (як у Nomad)Material / M3-ish«bg / fg» (часто в web DS)Apple HIG-орієнтир
Фон екранаbackgroundsurface / backgroundbg.canvas / bg.appsystem background
Картка, панельsurfacesurfaceContainerbg.elevated / bg.cardsecondary system background
Основний текстtextonSurfacefg.defaultlabel (primary)
Другорядний текстtextSecondaryonSurfaceVariantfg.muted / fg.subtlesecondary label
Головна діяprimaryprimaryaccent / brandtintColor
Текст на primaryonPrimaryonPrimaryfg.onAccent(контраст на tint)
Обводка / розділювачborderoutlineborder.defaultseparator
Помилка / небезпекаdangererrorfg.danger / bg.dangersystem red (семантика)
Успіх (опційно)successtertiary / customfg.successsystem green
Попередження (опційно)warningcustomfg.warningsystem orange
Приглушений / disabledmuted / disabledonSurface + opacityfg.disabledquaternary label

Прості ключі в одному об’єкті — зручно на старті:

colors.background
colors.surface
colors.text
colors.textSecondary
colors.primary
colors.onPrimary
colors.border
colors.danger

Мало вкладеності, легко читати в JSX: colors.surface.

Що обрати. Для Nomad і більшості навчальних / середніх продуктів вистачає плоского словника з 8–12 ключів. Вкладені bg/fg або повний M3 мають сенс, коли ролей десятки й дизайн уже так назвав токени в Figma. Не змішуйте в одному проєкті primary і brand і accent для тієї ж кнопки.

Ролі, які реально потрібні на старті Nomad

background
роль
Фон екрана (background / canvas). У light — світло-сірий/off-white, у dark — майже чорний. Користувач бачить «колір застосунку за картками». Синоніми в інших системах: bg.canvas, system background.
surface
роль
Поверхня піднятого блоку: картка, модалка, нижня панель. Зазвичай трохи інша, ніж background, щоб картка відділялась від полотна без обов’язкової жирної рамки. Синоніми: bg.card, surfaceContainer.
text / textSecondary
роль
Основний і другорядний текст. Secondary — підписи, дати, підказки: менший контраст, але все ще читабельний. Синоніми: onSurface / onSurfaceVariant, fg.default / fg.muted.
primary / primaryPressed
роль
Акцент дій (кнопка «Нова поїздка», активний чіп). Pressed — трохи темніший/світліший відтінок на час дотику. Синоніми: brand, accent, tintColor.
border
роль
Лінії розділення, обводка картки. У dark бордери часто світліші за фон, але не білі «як крейда». Синоніми: outline, separator.
onPrimary
роль
Текст/іконка на тлі primary (зазвичай білий). Окрема роль, щоб не плутати з text екрана. Синонім у M3: onPrimary.
danger
роль
Помилки, деструктивні дії. У dark часто трохи м’якший червоний, щоб не «палав» на чорному. Синоніми: error, destructive.
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff

rectangle "Компонент TripCard\n(не знає hex)" as C {
  rectangle "backgroundColor: colors.surface" as S
  rectangle "color: colors.text" as T
  rectangle "borderColor: colors.border" as B
}

rectangle "Тема light" as L {
  rectangle "surface = #FFFFFF" as L1
  rectangle "text = #0F172A" as L2
}

rectangle "Тема dark" as D {
  rectangle "surface = #1C1C1E" as D1
  rectangle "text = #F5F5F7" as D2
}

C --> L : useTheme().colors
C --> D : useTheme().colors

@enduml

Правило однією фразою: компонент знає роль («я поверхня картки»), тема знає конкретний колір для light і dark.

Демо: семантичні ролі + весь StyleSheet API

Нижче — один екран, де:

ЩоДе в демо
colors.background / surface / text / textSecondary / primary / onPrimary / border / dangerфон, картка, тексти, кнопка, обводка, рядок помилки
StyleSheet.createгеометрія styles.*
StyleSheet.flattenпідпис «title fontSize = …»
StyleSheet.hairlineWidthлінія під заголовком картки
StyleSheet.absoluteFillObjectбейдж «NEW» у куті
StyleSheet.absoluteFillоверлей «Видалення…» поверх картки
масив стилівкнопка primary / pressed / danger

Перемикач light/dark змінює лише словник c — JSX і styles ті самі.

TSXTokensAndStyleSheetAll.tsx
iPhone
9:41

Loading…

react-native-web · not a real device

Що зазвичай не міняють між темами

  • Відступи (spacing: 4, 8, 16…) — ритм сітки той самий.
  • Радіуси карток і кнопок.
  • Розміри шрифтів (інколи в dark трохи збільшують міжрядковість, але це вже полірування).

Міняються переважно кольори і інколи тіні (на темному тлі важка тінь майже невидима — інколи зменшують opacity або покладаються на border).

Що бачить користувач / що ламається

  • Добре: перемкнув тему — екран і картки узгоджено змінили theme, кнопка лишилась «головною дією» (primary), фото обкладинки поїздки не інвертувалось.
  • Погано: фон став чорним, а текст у AppText лишився #0F172A — майже невидимий. Причина: десь лишився зашитий hex або tokens.colors зі старої світлої-only таблиці.

Системна тема: що це в житті

На iPhone: Налаштування → Екран і яскравість → Світлий / Темний / Авто.
На Android: схожий перемикач у налаштуваннях дисплея (назви залежать від виробника).

Коли користувач обирає темний режим, система повідомляє застосункам: «зараз dark». Багато системних екранів і якісних застосунків підлаштовуються. Ваш код отримає це через API Appearance / хук useColorScheme.

useColorScheme()

useColorScheme() — хук React Native, який повертає поточну схему кольорів:

  • 'light' — світла;
  • 'dark' — темна;
  • null — модуль Appearance недоступний (рідко на реальному телефоні; трапляється в нестандартних середовищах).

Хук підписується на зміни: користувач увімкнув Dark Mode — компонент перерендериться з новим значенням. Вам не треба самим крутити таймери чи «перевіряти раз на хвилину».

Під капотом — модуль Appearance:

  • Appearance.getColorScheme() — зчитати зараз (без підписки);
  • Appearance.addChangeListener(...) — імперативна підписка.

У UI-коді достатньо хука: він уже підписаний і зручний у функційних компонентах.

Аналогія з вебом.window.matchMedia('(prefers-color-scheme: dark)') + listener. У RN — useColorScheme(), без ручного matchMedia.
TSXUseColorSchemeDemo.tsx
iPhone
9:41

Loading…

react-native-web · not a real device

Лише система — мало для продукту. Багато людей хочуть: «телефон у мене темний, але цей застосунок лиши світлим» (наприклад, читати при денному світлі) або навпаки. Тому зрілі продукти дають вибір:
  • Як у системі (system) — слухаємо useColorScheme;
  • Завжди світла (light);
  • Завжди темна (dark).
Тоді useColorSchemeодин із входів для режиму system, а не єдине джерело істини для всього UI. Нижче з’явиться preference + функція resolve.
Браузер ≠ телефон. У ::react-native-previewuseColorScheme може йти від теми сайту або браузера. У Expo Go — від симулятора/телефона. Завжди перевіряйте Dark Mode там, де користувач реально відкриє застосунок.

Дві палітри з однаковими ключами

Чому саме дві таблиці, а не isDark ? '#000' : '#fff' у кожному рядку?

  1. Один контракт для TypeScriptColorTokens з тими ж полями; забутий ключ у dark видно одразу.
  2. Дизайн узгоджує набори, а не «випадкові тернарники» в 40 файлах.
  3. Компонент не розгалужується на light/dark — лише читає colors.X.
export const lightColors = {
  background: '#F8FAFC',
  surface: '#FFFFFF',
  text: '#0F172A',
  textSecondary: '#64748B',
  primary: '#2563EB',
  primaryPressed: '#1D4ED8',
  border: '#E2E8F0',
  danger: '#DC2626',
  onPrimary: '#FFFFFF',
} as const;

export const darkColors = {
  background: '#000000',
  surface: '#1C1C1E',
  text: '#F5F5F7',
  textSecondary: '#A1A1AA',
  primary: '#3B82F6',
  primaryPressed: '#2563EB',
  border: '#3A3A3C',
  danger: '#F87171',
  onPrimary: '#FFFFFF',
} as const;

Перемикання в одному місці:

function colorsForScheme(scheme: 'light' | 'dark') {
  return scheme === 'dark' ? darkColors : lightColors;
}

const colors = colorsForScheme(scheme);

Усі екрани читають colors.*жодного #F8FAFC у TripCard.

background vs surface — навіщо два «темні» / два «світлі»

Якщо і екран, і картка одного кольору, список зливається в одну пляму. У світлій темі часто: фон екрана злегка сірий (background), картка біла (surface). У темній: фон чорний, картка темно-сіра (#1C1C1E тощо). Користувач відчуває глибину без важких тіней.

Контраст і доступність (на рівні інтуїції)

Контраст. У dark не достатньо «зробити фон чорним». Сірий текст #64748B на #000 може стати занадто тьмяним; інколи secondary у dark роблять світлішим (#A1A1AA). Перевіряйте на реальному екрані (яскравість, OLED). Повний аудит a11y — окрема тема пізніше; зараз мінімум: прочитати заголовок і підпис очима.

Чого не класти в палітру теми

  • URL фото обкладинки;
  • «магічні» відступи layout (вони в spacing);
  • колір, який завжди білий на логотипі вендора (окремий виняток, не text).

Приклад: перемикач + семантичні кольори

TSXThemeTogglePlayground.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Тема в усьому застосунку: ми самі пишемо ThemeProvider

Головне, щоб не загубитись.
ThemeProvider і useThemeне готові API з React Native і не «магія Expo». Їх пишемо ми (у Nomad — файли в src/shared/theme/).
React дає лише загальний механізм Context («коробка зі значенням для нащадків»). Ми кладемо в цю коробку тему і даємо зручні імена: Provider і хук.

Досі в демо тема жила всередині одного екрана:

const [mode, setMode] = useState<'light' | 'dark'>('light');
const c = mode === 'dark' ? dark : light;

Це нормально для однієї сторінки. У реальному застосунку з’являються Home, TripCard, Button, AppText, Screen — і всім потрібен той самий colors. Якщо передавати colors пропсом зверху вниз через п’ять рівнів — це prop drilling (prop drilling): «прокидання» даних лише заради того, щоб дістатися до далекого нащадка.

App
 └─ colors={c} → Layout
      └─ colors={c} → Home
           └─ colors={c} → TripCard   ← насправді колір потрібен лише тут і в Button

Хочеться інакше: один раз покласти тему «зверху», а будь-який компонент глибоко в дереві сказав: «дай мені поточні colors».

Звідки береться ідея: React Context (коротко з нуля)

У React (і на вебі, і в RN) є три цеглини:

ЦеглинаЩо цеАналогія
createContextСтворює канал (контекст) — «про що ми говоримо» (тема, мова, user…)Спільний «канал» ThemeContext
<Context.Provider value={…}>Кладе значення в канал для всіх нащадків усерединіКладете value у Provider
useContext(Context)Читає значення з найближчого Provider вище по деревуБудь-який нащадок читає value
Без Provider читати нічого. Якщо викликати useContext (або наш майбутній useTheme) поза<Provider>, отримаєте null / дефолт / помилку — бо Provider вище по дереву немає. Це найчастіша помилка новачка.

ThemeProvider у нашому коді — це звичайний React-компонент, який:

  1. тримає стан (що обрав користувач: system / light / dark);
  2. читає систему через useColorScheme;
  3. рахує scheme і colors;
  4. обгортає children у <ThemeContext.Provider value={…}>.

useThemeкороткий хук, який робить useContext(ThemeContext) і кидає зрозумілу помилку, якщо Provider немає.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff

rectangle "Ви пишете файл theme.tsx" as YOU {
  rectangle "1. createContext → ThemeContext" as C1
  rectangle "2. function ThemeProvider" as C2
  rectangle "3. function useTheme" as C3
}

rectangle "Корінь застосунку\n(app/_layout.tsx)" as ROOT {
  rectangle "ThemeProvider\nобгортає дерево нащадків" as P
}

rectangle "Будь-який нащадок" as CHILD {
  rectangle "const { colors } = useTheme()" as U
}

YOU --> ROOT : імпорт ThemeProvider, useTheme
ROOT --> P
P --> CHILD : value з colors, scheme, …
CHILD --> U

@enduml

Крок за кроком: збираємо тему самі

Нижче — логіка того самого коду, що потім ляже в src/shared/theme/ThemeProvider.tsx у Nomad. Читайте по порядку: кожен крок додає одну ідею.

Крок 1. Палітри й resolve (уже були)

Ми вже маємо lightColors, darkColors, colorsForScheme(scheme).
Додаємо два поняття, які часто плутають:

СловоЗначення
preferenceЩо обрав користувач у застосунку: 'system' | 'light' | 'dark'
schemeЯка theme зараз фактично на екрані: тільки 'light' | 'dark'

Якщо preference = system, scheme береться з useColorScheme().
Якщо preference = light або dark, scheme дорівнює preference — система ігнорується.

type ThemePreference = 'system' | 'light' | 'dark';
type ColorSchemeName = 'light' | 'dark';

function resolveScheme(
  preference: ThemePreference,
  system: string | null | undefined,
): ColorSchemeName {
  if (preference === 'light' || preference === 'dark') {
    return preference; // користувач примусово
  }
  // preference === 'system'
  return system === 'dark' ? 'dark' : 'light'; // null → light
}

Користувач тисне чіп

setPreference('dark') / 'light' / 'system'.

Provider читає ОС

const system = useColorScheme()'light' | 'dark' | null.

Рахуємо scheme

resolveScheme(preference, system).

Рахуємо colors

colors = colorsForScheme(scheme) — словник hex за ролями.

Нащадки лише читають

const { colors, scheme } = useTheme() — без знання, чому зараз dark.

Крок 2. Створюємо Context — «канал теми»

import { createContext, useContext, useMemo, useState, useCallback, ReactNode } from 'react';
import { useColorScheme } from 'react-native';

// що лежить у «скриньці» для всіх нащадків
type ThemeContextValue = {
  scheme: 'light' | 'dark';
  preference: 'system' | 'light' | 'dark';
  setPreference: (value: 'system' | 'light' | 'dark') => void;
  colors: typeof lightColors; // той самий набір ключів
};

// null = «Provider ще не обгорнув» (так ми зловимо помилку в useTheme)
const ThemeContext = createContext<ThemeContextValue | null>(null);

ThemeContextне компонент. Це об’єкт-канал. Компонент — ThemeContext.Provider.

Крок 3. Пишемо компонент ThemeProviderми його створюємо

Ім’я ThemeProvider — домовленість (як AuthProvider, StoreProvider). React його «не знає», поки ви не оголосите функцію:

export function ThemeProvider({
  children,
  initialPreference = 'system',
}: {
  children: ReactNode;
  initialPreference?: 'system' | 'light' | 'dark';
}) {
  // 1) що обрав користувач у застосунку
  const [preference, setPreferenceState] = useState(initialPreference);

  // 2) що каже операційна система
  const systemScheme = useColorScheme();

  // 3) фактична theme (scheme)
  const scheme = resolveScheme(preference, systemScheme);

  // 4) словник кольорів для цього scheme
  const colors = useMemo(() => colorsForScheme(scheme), [scheme]);

  const setPreference = useCallback((value: typeof preference) => {
    setPreferenceState(value);
  }, []);

  // 5) пакуємо все, що віддамо нащадкам
  const value = useMemo(
    () => ({ scheme, preference, setPreference, colors }),
    [scheme, preference, setPreference, colors],
  );

  // 6) кладемо value у Context і рендеримо `children`
  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

Хто «створює» ThemeProvider? Ви — у файлі ThemeProvider.tsx (або поруч із палітрами).
Хто його «вмикає»? Корінь застосунку — обгортає дерево: <ThemeProvider>…</ThemeProvider>.
Без цієї обгортки useTheme нижче немає звідки взяти value.

Крок 4. Пишемо useTheme — зручне читання Context

Замість того щоб у кожному файлі писати useContext(ThemeContext) і перевіряти null, робимо один хук:

export function useTheme(): ThemeContextValue {
  const ctx = useContext(ThemeContext);
  if (!ctx) {
    throw new Error(
      'useTheme() потрібно викликати всередині <ThemeProvider>. ' +
        'Обгорніть корінь застосунку в ThemeProvider.',
    );
  }
  return ctx;
}

Тепер у TripCard, Button, Screen:

const { colors } = useTheme();
// colors.surface, colors.text, …

useTheme з’являється не з повітря — це 5 рядків, які ви експортуєте з того ж модуля, що й ThemeProvider.

Крок 5. Підключаємо в корені (Expo Router)

У Expo Router корінь екранів — app/_layout.tsx. Саме тут Provider має обгорнути все, що викликає useTheme.

// useTheme у тому ж компоненті, де Provider ще «не вище себе»
export default function RootLayout() {
  const { colors } = useTheme(); // 💥 немає Provider-предка
  return (
    <ThemeProvider>
      <Stack />
    </ThemeProvider>
  );
}

Хук дивиться вгору по дереву. Сам RootLayout не є дитиною свого return — Provider з’являється лише для <Stack />.

Правило.useTheme() — лише в компонентах, які рендеряться як нащадки<ThemeProvider>. Якщо треба тема в корені — винесіть «внутрішній» компонент під Provider (як RootNavigator вище).

Що саме лежить у useTheme() (контракт)

ПолеТип (ідея)Навіщо
scheme'light' | 'dark'Фактичний scheme: StatusBar, підпис «зараз темна»
preference'system' | 'light' | 'dark'Що обрано в UI (який чіп підсвітити)
setPreferenceфункціяЗмінити вибір (чіпи на домашньому екрані)
colorsсловник ролейbackground, surface, text, primary
spacing / radius / fontSize(у Nomad)Токени форми — спільні для light і dark

У мінімальному варіанті достатньо перших чотирьох полів; spacing можна додати, коли вже є tokens.ts.

StyleSheet + тема разом (формула)

const { colors, spacing } = useTheme();

// геометрія — зі styles (модуль, один раз)
// колір — з теми (на кожен render, якщо scheme змінився)
<View
  style={[
    styles.card,
    {
      backgroundColor: colors.surface,
      borderColor: colors.border,
      padding: spacing?.md ?? 16,
    },
  ]}
/>

Так ви не пересоздаєте StyleSheet на кожну зміну теми і не ховаєте hex у create на рівні файлу.

Демо: мінімальний ThemeProvider «з нуля» в одному файлі

Нижче — повний цикл у demо: createContextThemeProvideruseTheme → екран з чіпами. Це та сама ідея, що в Nomad, лише без окремих файлів і без Router (у прев’ю немає @/…).

TSXMiniThemeProvider.tsx
iPhone
9:41

Loading…

react-native-web · not a real device

Розбір демо:

  1. ThemeContext = createContext(null) — порожній канал.
  2. ThemeProvider — ваш компонент: state + useColorScheme + Provider value={…}.
  3. useTheme — читає канал; без Provider кине помилку.
  4. HomeScreen — ніколи не імпортує lightColors напряму; лише useTheme().colors.
  5. export default function Appєдине місце, де з’являється обгортка <ThemeProvider><HomeScreen /></ThemeProvider>.

Якщо прибрати обгортку й лишити лише <HomeScreen /> — червоний екран: «useTheme() лише всередині ThemeProvider».

Зв’язок з файлами Nomad

У проєкті те саме розкладають по файлах (читабельність), але суть ідентична:

ФайлВідповідальність
src/shared/theme/colors.tslightColors, darkColors, colorsForScheme
src/shared/theme/tokens.tsspace, radius, fontSize (форма, не light/dark)
src/shared/theme/ThemeProvider.tsxContext + ThemeProvider + useTheme
src/shared/theme/index.tsреекспорт, щоб імпортувати @/shared/theme
app/_layout.tsxвмикає <ThemeProvider> навколо навігації
Підсумок одним реченням. React дає Context; ви створюєте ThemeProvider і useTheme; корінь застосунку один раз обгортає дерево; кожен екран/кнопка читає useTheme().colors замість hex і замість prop drillingу.

StatusBar — окремий шар «системного хрома»

Status bar (рядок стану) — зона зверху з годинником, батареєю, сигналом. Її малює система, не ваш View. Колір іконок на ній треба узгодити з фоном:

  • темний фон екрана → світлі іконки;
  • світлий фон → темні іконки.

З пакетом expo-status-bar (уже в Expo-проєктах), у компоненті всередині Provider:

import { StatusBar } from 'expo-status-bar';
import { useTheme } from '@/shared/theme';

const { scheme } = useTheme();
<StatusBar style={scheme === 'dark' ? 'light' : 'dark'} />

Плутанина в назвах:

  • style="light" означає світлі іконки (для темного фону);
  • style="dark"темні іконки (для світлого фону).

У core API StatusBar з react-native: barStyle="light-content" | "dark-content" — той самий сенс, інші слова. У міні-проєкті blank можна core; у Nomad зручніше expo-status-bar.

Що ламається. Забули StatusBar при dark: чорний фон + чорні іконки → користувач не бачить час і батарею. Це виглядає як «зламаний» застосунок, хоча «основний» UI ок.

Антипатерни (і що замість)

Ці помилки з’являються не тому, що хтось «не знає API», а тому, що тема роз’їжджається (дублюється) по проєкту без єдиного правила.


Міні-проєкт: Theme Lab

Окремий blank-проєкт (не Nomad). Мета — один екран з карткою профілю й перемикачем appearance.

Постановка

  1. Expo blank TypeScript.
  2. Дві палітри light / dark з однаковими ключами.
  3. Стан mode: 'light' | 'dark' (для міні-проєкту без «system» достатньо; system додасте в Nomad).
  4. Картка й фон беруть кольори лише з палітри.
  5. Підпис показує поточний режим.

Крок 1. Проєкт

npx create-expo-app@latest theme-lab -t blank-typescript@sdk-54
cd theme-lab
npx expo start

Крок 2. Замінити App.tsx

Повний лістинг згорнуто — розгорніть, щоб скопіювати.

Крок 3. Анатомія

Дві палітри, одні ключі

light і dark — однакові поля. Компонент ніколи не пише #000 напряму для фону екрана.

const c = mode === 'dark' ? dark : light

Один об’єкт c на рендер — усі c.surface, c.text.

StyleSheet для геометрії

padding, borderRadius, flex — у styles. Кольори — у масиві / inline від c.

StatusBar

barStyle залежить від mode (у core StatusBar; у Expo-проєктах часто expo-status-bar з style="light" | "dark").

Критерій «готово»

  • Перемикач реально змінює фон, картку, текст.
  • У JSX картки немає hex поза палітрами.
  • Статус-бар читабельний в обох режимах.

Результат міні-проєкту

TSXThemeLab.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Nomad: light / dark + семантичні токени

Навіщо користувачу

У метро ввечері білий екран сліпить. Nomad має слідувати системі або примусовій light/dark і малювати UI з однієї семантичної палітри.

Нитка проєкту

Уже є (з попередніх статей — не викидаємо):

  • Shell home: header + ScrollView зі стрічкою + sticky «Нова поїздка» (07).
  • Кольори з однієї світлої палітри в tokens (темна тема ще не працювала).

Додаємо в цій статті:

  • colors.ts — light/dark палітри; ThemeProvider + useTheme.
  • Примітиви (Screen, AppText, Button) і TripCard читають colors.* з теми.
  • На home — чіпи Система / Світла / Темна (лишаються в шапці).
  • StatusBar підлаштовується під scheme у _layout.

Повний знімок проєкту після цієї статті

Готовий код 1:1 з Nomad. Найшвидший шлях: git pull у клоні репо. Нижче — весь проєкт на момент цієї статті (усі файли з кодом), не фрагмент «лише нові папки». Клік по файлу в дереві → повний вміст для копіпасту.
cd /path/to/nomad
git pull
npm install
npx expo start

Перевірка

  1. Чіп «Темна» — темний фон, світлий текст, темні картки.
  2. «Світла» / «Система» працюють як очікується.
  3. Статус-бар не зливається з фоном.

Коміт

Якщо збирали вручну (не через git pull на вже запушений репо):

cd /path/to/nomad
git add -A
git commit -m "$(cat <<'EOF'
feat: light/dark theme with semantic color tokens

Material: content/15.react-native/08.stylesheet-and-theming.md
EOF
)"
git push

У публічному репо цей коміт уже є — після git pull повторно комітити не потрібно, якщо ви не змінювали код локально.

Результат: чіпи теми + стрічка

TSXNomadTheme.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Підсумок

Якщо стиснути статтю до ланцюжка рішень:

  1. Форма екрана — Flexbox + StyleSheet (відступи, радіуси, flex).
  2. Theme — словник ролей colors.*, не розкиданий hex.
  3. Звідки theme — system (ОС) і/або вибір користувача → один schemecolorsForScheme.
  4. Як рознести по дереву — ми пишемо ThemeProvider + useTheme на React Context (без prop-drilling).
  5. Системний хром — StatusBar узгодити з scheme.
  6. Перевірка — очима на телефоні в light і dark.

Далі — списки й віртуалізація: FlatList, секції, pull-to-refresh — коли карток стає багато. Тема лишиться з вами: віртуалізований список теж малює colors.surface на рядках.


Практичні завдання

Базовий рівень

  1. Пояснити різницю між colors.surface і #FFFFFF у коді компонента.
  2. Зібрати style={[styles.base, on && styles.on]} на кнопці.
  3. Показати useColorScheme() текстом на екрані.

Середній рівень

  1. У theme-lab додати третій режим «system» через useColorScheme.
  2. У Nomad змінити primary у dark-палітрі й перевірити кнопку + чіпи.
  3. Винести hairlineWidth на розділювач header/footer.

Професійний рівень

  1. Зберегти preference у AsyncStorage (або MMKV пізніше) між перезапусками.
  2. Додати elevation / тінь, що слабшає в dark (окремі токени shadowOpacity).
  3. Порівняти (абзац) Context-тему vs майбутній RTK slice settings.theme.

Часті запитання

Copyright © 2026