Галоўная / Артыкулы / Пораўнанне дзевяці тэхнік режыму темнай палітры: ад хакоў з фільтрамі да кукіяў сервера.

Пораўнанне дзевяці тэхнік режыму темнай палітры: ад хакоў з фільтрамі да кукіяў сервера.

Пораўняйце дзевяць спосабоў дадаць режым темнай палітры да веб-дзеянасці: ад інверсіі фільтраў да токенавання, функциі light-dark() і кукіяў сервера, а таксама дазвольце сабе дакладна разумець, якія багі кожны з іх таямна стварае.

5596 слоў

Режым темнай прозрыці зазвычай практыкуецца як выбор между швыдкім рашэнням і адпраўным спосабам, але на сайтах у працэўнай сітцы викорыстоўваецца ўжо прынеймна дзевяць разных тэхнік, і кожная з іх рашае толькі частку проблемы. Тыя аспекты, якія ігнаруецца тэхнікаю, часта стаюць прычыной мерканняя стороніц, зламаных фіксаваных заголовкаў аб кольораў, якія тыхо не хочуць зменіцца. У гэтым кярыеры размешчаны тые дзевяць падходаў, апісана, што кожны з іх робіць правільна, а шта — няправільна, а таксама рассказаны деталі, якія ствараюць проблемы нават пад час адказнае рэалізацыі: мерканнея неправильнага тэмы, атрыбут color-scheme, настройкі троха станоў, пераходы, вбудаваны контэнт і дизайн палітры.

Указаныя нижэй поведенчыскія твердзенні падкрэплены первычнымі матэрыяламі: праектамі CSS Working Group, стандартам HTML ад WHATWG, машыннымі дадзеннямі browser-compat-data, якія викорыстоўваюцца на MDN, дадзеннямі статусу Baseline і кансольным кодам бібліятэкі next-themes; гэтыя матэрыялы былі перапрацаваны паўтарна з викорыстаннем безвіконнага Chromium. Две популярныя думкі не вытрымаюць такага аналізу, а ўсё тое самае поведенні — filter, які фіксуе наследнікаў з position: fixed — апынулася набагато лепай прычыной ухіліцца ад трыкі з інверсіяй, чым расплывчаты аргумент пра выдатнасць, які зазвычай прыводзяцца.

Режым темнай палітры — це тры окремыя проблемы

„Дадзіце темную палітру“ звучыць як адна задача. Насправе ж гэта ўключае тры незалежныя аспекты, такія што нават якша вы задасце на ўсе з іх, прычым усё рав можа выйсці нешта несправнае:

  1. Калькі тэму паказваць? У аператывайной системе ёсць прыярэнне, якое можа быць змененаўцам адваротна, і ёй таксама патрэбны способы вернуцца да прыярэння системы. Простая настройка «включыць/выключыць» назаўсёды паскладвае гэты трэці варыянт.
  2. Як зменяюцца кольоры? Однае пераключанне павінна апдэйтаваць кожную поверхню, рамку, іконку і цэнтры, а яго распадзел фактычна вялікі тэму вашай CSS-архітектуры.
  3. Кал прыкладзваецца тэма? Якщо рашэнне прыходзіць пасля першага атрыбутавання, корыстувальнікі бачаць, як сторанка зменяе кольоры пры іхных вачох.

Кожная з тэхнік, паказаных нижыней, адпавядае часткі гэтых пытанняў. Патэрн ясны: так званыя методы «lazy» зазвычай рашаюць другое пытанне адособлена і ўсёж занедбваюць першае та трэцяе.

Як чытаць рангаванне

Тры найбольшыя позіцыі не ўзаемныя канкурэнты. Яны з’еднаюцца ў адну структуру: семантычныя токены ёсць базой, light-dark() — гэта болей компактны спосаб напісаць гэтыя токены, а кукі, якія чытае сервер, дапамагаюць доставіць выбраную тэму без викорыстання Flash. Пазіцыі чатвертая-шостая — гэта справжнія компромісы, калі трэба выбраць адна з варыянтаў. Пазіцыі сьмая-дзевятая — гэта тэхнічны дывыт.

Пазіцыя 9: інверсія всей сторонкі за дапамогою filter

Найкорачэйшы можлівы темны режым застосоўвае інверсію та ротацыю відтэнка да корневага элемента:

html {
  filter: invert(1) hue-rotate(180deg);
}

Гэта негайна прымушае застосавіць другое правіло, якое знову інвертуе кожны элемент медыя, так што фота та відэа знову будуць выглядаць нормальна:

/* now patch back everything it broke */
img, video, canvas, svg, [style*="url("] {
  filter: invert(1) hue-rotate(180deg);
}

Звычайна працовая прытлівасць — гэта аргумент, які люди часта выводзяць, але якой важка чыста падтвердзіць. Існуе ўжо болей сильны аргумент. Дакументацыя MDN пра блокі-контейнеры чытальна пазначае: калі filter заданы не як none, элемент стае контейнерам для наследнікаў з position: fixed і position: absolute. Це таксама стварыць новы контэкст складання, який таямніча зменшыць спосаб, яким вырахоўваецца кожны z-index пад якім.

Безгалавы тэст практычна паказвае гэта. Разместіце фіксаваную палку ўнутрь фільтруемага обгортка на сторанцы высотай 3000 пкс у Chromium 141, прасуніцеся вярху на 400 пкс і вымерыце, дзе знаходзіцца палка:

await p.evaluate(() => window.scrollTo(0, 400));
// -> { "fixed_viewportTop": 100, "abs_viewportTop": 100 }
// A truly viewport-fixed element reports top: 0 after any scroll.

У рэпорце паказана афкселія вікна прэвью 100 замест 0, таму ён больш не фіксаваны; ён праскае разам з контэнтам. Калі гэты фільтр застосаваць да html, ён нарабляе проблемы з усімі фіксаваными заголовкамі, фіксаванай навігацыяй, модальными пакрываламі, стакамі спавящых паведамленняў і відкрываючыміся меню на сторанцы. Це якостны дыфект, які можна перадаць за калькі ліній коду, а не пытанне смаку.

Іншыя проблемы ўсё тыя ж:

  • Кожны растровы асет трэба перевернуць, і логіі з вбудованымі кольрамі брэнду таксама выходзяць некоректнымі, тым болей што пераўранне відтэнка на 180 градусоў не є точным пераверненням у жадной кольоровай прасторы.
  • Кольры брэнду ператвараюцца на ўзэмныя матэматычныя протыпады, а не на запланаваную темную палітру.
  • Няма ніякіх автарытетных рэкамендацыйяў, якіе бы гэта прымалі. У рэкамендацыйях web.dev па темнай схеме ніколі не пропонуецца ператвараць цэлыя сторанкі; для медыя пропонуецца filter: grayscale(50%) для фота, а invert(100%) — толькі для монохромных айконаў.
  • Самыя рэкамендацыйі Chrome па аўтаматычнай ператварэнні сторанкі — стварыць спецыяльную темную схему замест таго, каб выключыць гэю функцыю.
  • Рангі 8 і 7: падходы, якія працуюць, пакуль не перестануць

    Змены для кожнага компонента

    Найпершым ежыстным падходам ёсць стварэнне темнай версіі для кожнага компонента. Гэта не столькі неправильна, сколькі неакуратна. Спачатку вы викорыстоўваете светлыя стылі:

    .card       { background: #fff; color: #14161a; }
    .card .btn  { background: #f0f2f5; }
    

    а пасля дадаеце темныя аналогі пад класам body:

    body.dark .card            { background: #121212; color: #fff; }
    body.dark .card .btn       { background: #333; }
    body.dark .card .btn:hover { background: #444; }
    

    Паколькі темныя правілы должны перамогчы светлыя, селектары становяцца все бол спецыфічнымі; body.dark .card .btn:hover вже мае чатыры элементы з самага пачатку. Значэння у формате HEX таксама змінююцца: #121212 у аднам файле, #111 у іншым, а #0f0f0f — у скопіяваным компоненте, і няма жаднага цэнтральнага месца для автоматычнай перапрацовкі контрасту. Асновная проблема масштабавання можна выразіць ў адной фразе: наявныя змены расту разам з колькасцю компонентов, а токены — разам з колькасцю ролей, пры чым большасць систем дизайна маюць адпрацоўваець прыблізна 12–20 роляў.

    Два адзелённых шаблона стылю

    Загрузка светлага і темнага шаблона стылю праз атрыбуты media выглядае эфектыва:

    <link rel="stylesheet" href="light.css" media="(prefers-color-scheme: light)">
    <link rel="stylesheet" href="dark.css"  media="(prefers-color-scheme: dark)">
    

    Ён не запобегае другаму завантажэнню, і самэлькі ў гэтым і крыўдзяцца. Як пояснюе стацыя на web.dev пра prefers-color-scheme, шаблон стылю, якога кэшаваная запитоўка медыя не падпадае, такі ж завантажваецца, толькі з найніжшым прыорітэтом, таму ён не можа конкуруваць з ресурсамі, якія нараз прынадзены старонцы. Прыдбанне — гэта корачэйшы критычны шлях, а не меншая колькасць байтов. Да таго ж атрыбут media чытае толькі настройкі ОС, таму жадны ручны пераключальнік не можа на яго вплыць; два файлы з часам часта становяцца несувязнымі; а таксама інструменты для зборкі можу неправямо адмаўляцца з іх, як паказана у проблеме Vite.

    Ранг 6: чыста-CSS пераключальнік з :has()

    Візуальна схованы прапуск і яго атрыбут можу служыць пераключальнікам:

    <input type="checkbox" id="theme" class="sr-only">
    <label for="theme">Dark mode</label>
    

    Корневы элемент тады реагуе на статус праперкі чераз :has():

    html:has(#theme:checked) {
      color-scheme: dark;
      --bg-surface: #1b1f27;
      --text-1:     #e8e6e3;
      --border:     #2b313c;
    }
    

    Для гэтага сапраўды не трэба JavaScript, а :has() стаў «шырока доступным» 19.06.2026 года. Важна два моменты. Першы — трэба змяніць кольоры, як і color-scheme, як паказана ў прыкладзе. Другі — статус знаходзіцца толькі ў DOM, таму кожны запуск стораніцы пачынаецца з нуля і нічога не захоўваецца. Таксама немагчыма адобразіць тры статусы чыста; для гэтага патрэбны радія-кнопкі і больш ветак селектараў. Гэта падходзіць для дамы, CodePen чы аднасторанніх дакументаў, але не для продукту.

    Ранг 5: варіянт dark: у Tailwind

    Варіянт dark: не ўскладнены, проста кольоровыя рашэнні размешчаны не там: у шаблонах, якія павтараюцца на кожным месцы выклікання.

    <div class="bg-white dark:bg-zinc-900
                text-zinc-900 dark:text-zinc-100
                border-zinc-200 dark:border-zinc-800
                hover:bg-zinc-50 dark:hover:bg-zinc-800">
    

    Рэзультатам ёсць дужа дугія строкі класоў, якія не магуць быць пераглянутыя, таму што темныя кольоры распадзены ў шаблонах, а таксама існуе дадатковыя тэмы, такія як высокая контрастнасць, або спецыяльныі дизайн брэнда, які працуе на адной зместы маркапа, а не дадае ўтворэння новага шара. Гэта таксама нічога не робіць для інтерфейсу, створанага браузерам, і все раве патрэбны окольны скрыпт, каб узбегнуць мерклівання.

    Рашэнне знаходзіцца ў самым Tailwind. Праеўначыце кольоры тэм на CSS-зменныя і зменіце іх толькі адназначна; пасля чаго можна будет выкарыстоўваць bg-surface без жаднага прымака dark:. Пачніце з стандартнага імпорту:

    @import "tailwindcss";
    

    Пасля чаго неабходна запрацаваць спецыяльную варіант класу, паказаць кольоры тэм на адпаведныя зменныя і задаць кожнай тэме своія значэння зменных:

    @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));@theme {
      --color-surface: var(--surface);
      --color-content: var(--content);
    }:root               { --surface: #f4f5f7; --content: #14161a; color-scheme: light; }
    [data-theme="dark"] { --surface: #1b1f27; --content: #e8e6e3; color-scheme: dark; }
    [data-theme="hc"]   { --surface: #000000; --content: #ffffff; color-scheme: dark; }
    

    Кэшаванец :where() выклікана намераванне. Ён не дае темнай версіі жадных дапаможных спэцыфікацый, таму функцыі dark: ніколі не перакрыюць вялікія стылі. Дадзенне тэмы з высокай контрастнасцю коштае адзін рядок, а не перагляд кожнага шаблона.

    Ранг 4: выключваець толькі prefers-color-scheme

    Найпростейшы і надзеяны варыянт — гэта задаць значэння для светлай темы як стандарт і перазадаць іх унутрь запиту медыа:

    :root {
      color-scheme: light;
      --bg-base: #ffffff;  --text-1: #14161a;
    }
    
    @media (prefers-color-scheme: dark) {
      :root {
        color-scheme: dark;
        --bg-base: #12141a;  --text-1: #e8e6e3;
      }
    }
    

    Няма JavaScript, няма Flash і нічога, што трэба было бы запрацоўваць. Це найшвэдзейшы спосаб у всім списку, а яго ўжо едын недзеялівы мінус є значны для дапрыемств, але не мае значэння для контента: корыстувальцы не можаць змяніць настройкі системы.

    Якщо ніхто ніколи не прасіваў прыема для пераключэння кольоровой схемы, як это звычайна для блогаў, дакументацыі, логаў змян і стораніцаў маркетынгу, зупініцеся тут. Няма нічога кращага для такіх сайтаў у даным списку.

    Есць два деталі спефікацыі, якія варта знать:

    • Media Queries Level 5 паветарае, што гэтыя можлівасці могу практычна адзначацца большымі значэннямі ў будучыне, прыкладам служыць сепія, і рэкамендуе теставацыю праз негатыў: (prefers-color-scheme: dark) проты (not (prefers-color-scheme: dark)), а не проста падтрымкі light.
    • Значэнне no-preference было адмовлена. Яго няма ў чынной спефікацыі, і жадны браузер яго не падтрымляе; корыстнік без выбору кольоровай схемы падтрымлівае light.

    Ранг 3: light-dark() і яго спосаб бяспечнага абходжэння

    light-dark() прыблізна падвайкае размах файла з токенамі, калі ў адной декларацыі знаходзяцься обе ціні:

    :root { color-scheme: light dark; }   /* REQUIRED */
    
    .card {
      background:   #fff;                          /* fallback for old browsers */
      background:   light-dark(#fff, #1b1f27);
      color:        light-dark(#14161a, #e8e6e3);
      border-color: light-dark(#e2e5ea, #2b313c);
    }
    /* a manual override becomes ONE property write */
    [data-theme="dark"]  { color-scheme: dark; }
    [data-theme="light"] { color-scheme: light; }
    

    Это ўжо цэлая система тэм без якіх-лебе блака @media чы ўтварэння другога правіла :root. Ручная замена выражаецца ў заданні color-scheme на роўні корня.

    Але гэта коштуе командам гадзіны часу. Адработка кальколькі варыянтав у Chromium 141 з прымусовым выкарыстоўванням кольоровай схемы з двух бакоў даў тры важлівыя нарадкі:

    1. Без color-scheme light-dark() нічога не робіць. У темной сістэме ён тыха вяртае светлы калер, і нічога ў консолі не паводзіць на проблему. Гэта найпашыльнейшы баг light-dark(), і ён застаецца непазірным, пакуль хтось у темной сістэме пра ўсё гэта не запаведзіць.
  • Заявленне color-scheme: dark на элементе прымушае яго выбраць другі аргумент, незалежна ад настройкаў операцыйной системы. Самэльчына прычына — гэта ўскладнюе роботу з кімвалідацыяй, таму ўсё заўсёды вырабляецца праз адзін запіс властыбета, а не праз змену класа і додатковы набор правілаў.
  • Установка color-scheme: dark на элемент, які не ўтварае корневага елемента, не дае яму темнага фона. Гэта зменяе кольоры системы і встроеныя элементы керування, але фон canvas паўтарае толькі настройкі корневага элемента. Гэта найпасёлейшая неправильнае разумеванне гэтага властыбета.
  • Як пераканальны тэст для вашай власнай палітры, у режыме темна Chromium вычысляе кольоры системы Canvas як rgb(18, 18, 18), што адпавядае #121212 — таму ж, што і прызначае Material для темных тэм.

    Перш чым пасполагацца на гэта, важкае ўважненне да следуючых застерэжэнняў:

    • Это базавыя значэння „наўколішча доступныя“, а не „шырока доступныя“: Chrome і Edge 123, Firefox 120 і Safari 17.5; дата наўколішча доступнасці — 2024-05-13, таму праграма шырокай доступнасці адбываецца прыблізна 2026-11-13.
    • Ён не працуе належным чынам у разы ўскладненняя ситуацыі. Браузеры без падтрымкі адхоўляюць усю декларацыю як нэвалідную, таму завжды трэба спачатку задаць простую альтернатыўную версію для той самай властивасці, як і паказана ў прыкладзе.
    • Это значэнне кольору, таму яго нельзя выкарыстоўваць як умову для запытанняў пра медыя. Його выкарыстоўванне ўнутры декларацый у блоке @media ўсё ж дазволена; гэта аднае розныя речы.
    • Аргументы для зображэнняў, такія як light-dark(url(a.png), url(b.png)), з’явіліся толькі ў Chrome 150, Firefox 150 і Safari 27, согласна данным пра сумеснасць на момент напісання, якія ўжо занадта свежыя, каб на іх пасляваліцца.

    Адзін паведамленне пра дакументацыю: на сторанцы MDN з описамі функцыі light-dark() указаны Chrome 119 і Safari 17.2, тады як у самых данных MDN browser-compat-data і базовыя API паказуюць 123 і 17.5. Калі цыя даны не збігаюцца, трэба паверыць структураваным данным і пераканацца ў актуальных сторанках пры цитаванні версый.

    Ранг 2: сэмантычныя токены, што ўтвараюць аснову для всего іншаго

    Ключовая правіла — называць роль, яку выпалюе кольора, а не сам кольора. Пачніце з прымітываў, тое ўсіх первісных значэнняя палеты, якія компоненты ніколі не выкарыстоўваюць безпасэродзе:

    /* primitives: raw values, never consumed by components */
    :root {
      --gray-0: #ffffff;  --gray-50: #f4f5f7;  --gray-200: #e2e5ea;
      --gray-600: #55606e; --gray-900: #14161a; --gray-950: #12141a;
      --blue-500: #3b82f6; --blue-400: #60a5fa;
    }
    

    Над імі знаходзится сэмантычны шар. Значэнняя светлага кольору є стандартнымі, адна блок-значэння [data-theme="dark"] заменяе іх усі, а компоненты выкарыстоўваюць толькі назвы ролей, таму ім ніколі не трэба ведаць, який тэматык актыўны:

    /* semantic roles: light is the default */
    :root {
      color-scheme: light;
      --bg-base:    var(--gray-0);
      --bg-surface: var(--gray-50);
      --text-1:     var(--gray-900);
      --text-2:     var(--gray-600);
      --border:     var(--gray-200);
      --accent:     var(--blue-500);
      --shadow-sm:  0 1px 2px rgb(0 0 0 / 0.08);
    }/* one block flips the whole app */
    [data-theme="dark"] {
      color-scheme: dark;
      --bg-base:    #12141a;   /* grey, not #000 */
      --bg-surface: #1b1f27;   /* lighter = higher up */
      --bg-raised:  #232833;   /* lighter still */
      --text-1:     #e8e6e3;
      --text-2:     #a2acbb;
      --border:     #2b313c;
      --accent:     var(--blue-400);
      --shadow-sm:  0 1px 2px rgb(0 0 0 / 0.5);
    }/* components never know which theme is active */
    .card { background: var(--bg-surface); color: var(--text-1); border: 1px solid var(--border); }
    

    Зверніце увагу на деталі ў тамным блаке: фон мае тамно-сірый колер, а не чорны; поверхні становяцца светлейшымі чым вышэ ўони знаходзяцца; акцент пераходзіць на светлейшы варіант, а цэнтраўны тэней становіцца сильнейшым, ўсёлякія кольоры залишаюцца виднымі.

    Просты тэст памагае з’ясавіць, чы ў назве токена ёсць сэнс: чаму яна викорыстоўваецца, можна описаць без згадкі пра колер? Фраза «Фон панелі, якая знаходзіцца вышэ» описвае ролю, тады як «тамно-сірый» — конкрэтны кольор. Толькі ролі застаюцца незменнымі пасля змены тэмы, адколі токен, які называецца «тамно-сірым», ніколі не можа стаць тамным.

    Для самага пераключальніка атрыбут data-theme ўсуперакоўвае клас .dark. Ён па свойму прыродзе можа мястіць тры або больш значэнняў, не можа стварыць суперскуплення з класамі-функцыямі, а яго змена выражаецца ў прызначэнні значэння document.documentElement.dataset.theme. Чырвонейшае пра тое, як кераваць спецыяльнымі атрыбутамі пад час выканання, можна знайсці ў адгуке блога па тэме темавання за дапамой CSS-спецыяльных атрыбутаў.

    Пазірк 1: чытанне куку з тэмай на сервере

    Атрыбутаванне тэмы на сервере — гэта ўжо едын варыянт, які дазволяе ухіліцца ад усіх чатырох звычных проблем: вбудованага скрыпта, мгновеннага адображэння, несувярэннасці падчас гідратацыі і адхіленняя ад Палітыкі безпекі кантэнту, адтуды што сервер ведае тэму ўжо перад тым, як адправіць першы байт. У проекте Next.js App Router корневая структура імпортуе памагальны калектар для куку:

    // app/layout.tsx
    import { cookies } from 'next/headers';
    

    і запісвае зберажаны тэму безпосередна ў элемент html, пры чым якщо гэта немагчыма, выкарыстоўваецца светлы тэм:

    export default async function RootLayout({ children }) {
      const store = await cookies();                 // async since Next 15
      const theme = store.get('theme')?.value ?? 'light';  return (
        <html lang="en" data-theme={theme} style={{ colorScheme: theme }}>
          <body>{children}</body>
        </html>
      );
    }
    

    Змена тэмы выконваліцца ў дзеянні сервера, для чаго таксама патрэбна тая ж самая імпортацыя:

    // app/actions.ts
    'use server';
    import { cookies } from 'next/headers';
    

    Дзеянне запісвае выбор на год, які дыючы ўсім сайту:

    export async function setTheme(theme: 'light' | 'dark') {
      const store = await cookies();
      store.set('theme', theme, { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' });
    }
    

    Косты дакументаваны самай Next.js:

    • cookies() — это API, якае працюе пад час запиту, таму його вызов у лейауте або сторонцы пераводзіць гэты маршрут у режым дынамічнай відраслі і вы трапляецеся без статычнай паведамленняя перад відраслём.
    • Калі увыкнуты компаненты кэшу, вызов cookies() за межами <Suspense> таксама запячэляе паведамленняя перад відраслём.
    • HTTP не дазволяе ставіць кукі пасля таго, як запускаецца стрімінг, таму куку неабходна запісваць за дапамою .set у функцыі сервера або обробніку маршрута, ніколі пад час відраслі.
  • Кожны запрос несе кукі, яка дадае прыблізна 15 байта.
  • Наладжэнні операцыйной системы невидны серверу, таму выбор system все рава трэба вырашвац на кліянтэ. Практычны варыянт — использоваць кукі для явных выбораў і matchMedia для випадку системы.
  • Існуе кліянтскі сигнал Sec-CH-Prefers-Color-Scheme, які мог бы выявіць праганнення ОС серверу. Це толькі праект ад WICG, які ўключаецца толькі в Chromium і не ёсць стандартам, таму яго трэба спрыяваць як максимум як оптымізацыю, а не як загальна рашэння.

    Запобежэнне показу неправильнага тэмы

    Это трэція проблема з самага пачатку, і найбольш распашчытаныя дыфекты таму, калі веб-сайты працуюць у режыме темнага фона. У дыялогу «праві спосаб проты ледзьве дасканальнага спосабу» яго зазвычай вовсе не праменяюць. Часавая шкала паказвае, чаму запуск скрыпты з адкладаннем выходзіць занадта пазнела:

    DEFERRED SCRIPT:  [HTML][CSS][PAINT: LIGHT][JS][REPAINT: DARK]   <- user sees it
    BLOCKING INLINE:  [HTML][JS][CSS][PAINT: DARK]                   <- correct first paint
    

    Тэму трэба задаць у элементе html пры першай практычнай адрасаваніи экрана. Рашэнняя — маленькая вбудованая скрыпта ў частцы head, якая размешчаецца раней за файлы стылю:

    <head>
      <meta charset="utf-8">
      <meta name="color-scheme" content="light dark">
      <script>
        // inline. no src, no defer, no async, no type=module.
        (function () {
          try {
            var s = localStorage.getItem('theme');   // 'light'|'dark'|'system'|null
            var dark = s === 'dark' ||
              ((!s || s === 'system') &&
               matchMedia('(prefers-color-scheme: dark)').matches);
            var el = document.documentElement;
            el.dataset.theme     = dark ? 'dark' : 'light';
            el.style.colorScheme = dark ? 'dark' : 'light';
          } catch (e) { /* storage throws in private mode / sandboxed iframes */ }
        })();
      </script>
      <link rel="stylesheet" href="/app.css">
    </head>
    

    Кожныя абмежнення ў гэтым фрагменте маюць значэнне. Ён павінен быць вбудованым і сінхронным, без атрыбутаў src, defer, async чы рэжыму модуля, таму што будь-які з гэтых элементаў дазволіў браузеру спачатку намалёваць элемент. Ён чытае прэферанцію з трума значэннямі і вырашвае пытанне system за дапамою matchMedia. Ён задае як атрыбут, так і colorScheme, ўпэўніваючыся, што токены і интерфейс браузера састаюць у згодzie. Кроме таго, доступ да хранення дадзеных пакушаецца ў рамках try/catch, таму што localStorage выклікае падзеі ў режымах прыватнага перегляду і в інфрамаракетах з пескадж-боксамі.

    Праўая цена — это політыка безпекі: для вбудованага скрыпту патрэбны параметры 'unsafe-inline' чы спецыяльны nonce у вашай CSP. Якщо ваша політыка не дазволяе нітога з гэтага, трэба викорыстоўваць падход з кукі.

    У Next.js таксама неабяжна наявнае suppressHydrationWarning на элементе html, таму што скрыпт зміняе свае атрыбуты прытаму, калі React выкаанацьвае інфраструктуру, і яны больш не падходзяць да маркапу сервера. Як зазначае дакументацыя next-themes, гэты флаг дзейсніць толькі на адны ўровень, таму ён не маскіе паведамленняя пра выкаанацьванне інфраструктуры ў іншых месцах.

    Чытанне коду next-themes паказвае, як мало застосавана тут захітка пры запобiegанні спалахам. Ён выканае <script dangerouslySetInnerHTML>, кантэнт якога — це сама функцыя script(), ператвораная у строку за дапамою script.toString(), і яка негайнаа вызываецца з аргументамі, серыялізаванымі у формате JSON. Хук useTheme() вяртае значэння theme, setTheme, resolvedTheme, systemTheme і themes; пры выкананні на сервере значэнне theme ёсць undefined. Адрадзіце свой пераключальнік на адказе resolvedTheme пасля пераканалення, іншае — сама кнопка пераключальніка спрычыніць кэс з адхідратацыяй.

    color-scheme: атрыбут, які большасць сайтаў ніколі не насталяюць

    Яшчэк стылю адражоўвае тое, што вы напісалі, але сам браузер адражоўвае паліцы прасування, вучорнія элементы форм і плошчу адражоўвання за сторанай. Спецыфікацыя CSS Color Adjustment выклекчвае, каб працоўнік пользователя падтрымлваў усе гэтыя элементы ў спакое кольораў элемента:

    • стандартныя кольоры паліц прасування і інтэрактыўных элементаў UI
    • стандартны выгляд элементаў формы
    • дадатковыя элементы UI браузера, напрыклад падчыркванні для перакрыцча
    • сістэмныя кольоры, такія як Canvas, CanvasText, ButtonFace, Field і AccentColor
    • рэзультат выканання функцыі light-dark()

    У элементе-корзені схема таксама каналізуе колер паверхні canvas і полоскі прасоў вікна перагляду. Яго трэба заявіць у трохох месцах. Першае — таг meta, які парсер HTML бачыць перш чым прыйдзе які-небудзь CSS:

    <!-- parsed at HTML-parse time, BEFORE any CSS loads -->
    <meta name="color-scheme" content="light dark">
    

    Другае — правіла CSS, якія падтрымваюць супараднасць гэтаго атрыбута з атрыбутам тэмы:

    :root               { color-scheme: light dark; }
    [data-theme="dark"] { color-scheme: dark; }
    [data-theme="light"]{ color-scheme: light; }
    

    Трэцяе — за патрэбы, механізм блакавання, які выкарыстоўваецца для таго, каб адпаведны віджэт заставался светлым незалежна ад тэмы:

    /* force a widget to stay light regardless */
    .brand-widget       { color-scheme: only light; }
    

    Мета-таг не ўсунуты. Раздзел стандарта HTML пра мета-таг для кантролю схемы кольора існуе самэўсёлкава, каб браузер могаў адразу намалёваць фон стораніцы па правильной схеме, не чакаючы на стайл-шіты. Атрыбут CSS ведамы толькі пасля таго, як стайл-шыт быў завантажаны і апрацаваны; гэты прызнак выражаецца як момант белага фона. Стандарт таксама дазволяе наявнасць не больш як адного такога мета-элемента на документ.

    Ішчырэ два проблемных моменты:

    • Атрыбут і запит да медыа-кантэнту не асоціяваны. Заявленне color-scheme: dark ніколі не выклікае падтрымкі prefers-color-scheme: dark, таму код, які намагаецца вывучыць адны атрыбут на адной падставе іншага, будзе працаваць некоректна.
  • Ключавая слова only паведамляе браузеру, што ён не можа змяніць схему элемента. У практыцы гэта тое, як сторанка не дазволяе Chrome на Android застосаваць свой автаматычны темны шаблон. Його історія сумяшаная: быў дадзены ў Chrome 81, адмоўлены ў 85 і вярнуцься ў 98.
  • Тры станы заместо булеваў

    Як толькі параметр стане булевым, опцыя „следаваць за маім ОС“ знікне, і корыстнік не зможа яе вярнуць. Для гэтай настройкі патрабуюцца тры значэння: светлы, темны і системны. Пачніце з ключа для зберагчыка і спісу запитоў да медыя:

    const STORAGE_KEY = 'theme';
    const mq = matchMedia('(prefers-color-scheme: dark)');
    

    Рэшта логікі застосоўвае настройку, зберагае яе, чытае ўтрымкі з system як стандартнага значэння і продовжвае стежыць за змянамі ва ўпрабавачаеўскай СУТКІ толькі калі выбрана опцыя system:

    function apply(pref) {                     // 'light' | 'dark' | 'system'
      const dark = pref === 'dark' || (pref === 'system' && mq.matches);
      const el = document.documentElement;
      el.dataset.theme     = dark ? 'dark' : 'light';
      el.style.colorScheme = dark ? 'dark' : 'light';
    }function setPreference(pref) {
      try { localStorage.setItem(STORAGE_KEY, pref); } catch (e) {}
      apply(pref);
    }function getPreference() {
      try { return localStorage.getItem(STORAGE_KEY) || 'system'; }
      catch (e) { return 'system'; }
    }// keep following the OS, but ONLY while 'system' is the chosen preference
    mq.addEventListener('change', () => {
      if (getPreference() === 'system') apply('system');
    });apply(getPreference());
    

    Ўзайце addEventListener на об’екте MediaQueryList, а не addListener. Об’ект MediaQueryList тепер спадчынае ад EventTarget, і методы addListener oraz removeListener ўжо не падтрымваюцца, хоча багато прыкладаў у Інтэрнете яшчэ іх выкарыстоўваюць. next-themes навмесна заставляе выкарыстоўваць старыя методы, пры чым у коде є адпаведны коментар, каб падтрымаць старэйшыя версіі Safari.

    Кантролл должен быць групай з трох радыё-кнопак, а не прапускной кнопкай, таму што трох станоў патрэбныя трох элементаў уводу.

    Зупненне расплывання кольору пад час змены

    Якщо элементы на старонцы маюць переходы кольору, змена тэмаў анімавае сотні якостей адразу, і старонка візуальна растрыяецца. Рашэнне, якое выкарыстоўвае next-themes у сваёй опцыі disableAnimation, дадзеўнае тымчасовы стыль, які выключае всі переходы:

    function disableTransitionsTemporarily(nonce) {
      const css = document.createElement('style');
      if (nonce) css.setAttribute('nonce', nonce);
      css.appendChild(document.createTextNode(
        `*,*::before,*::after{ transition: none !important }`
      ));
      document.head.appendChild(css);
    

    Ён вяртае функцыю, якая восстанавляе переходы, а дапаможны метод swapTheme абгружае змену тэмаў между двумя станамі:

      return () => {
        // Deliberate forced synchronous reflow: commit the new colours
        // WHILE transitions are still off.
        (() => window.getComputedStyle(document.body))();
        // Remove on a later task, after the flush has committed.
        setTimeout(() => { document.head.removeChild(css); }, 1);
      };
    }function swapTheme(next) {
      const enable = disableTransitionsTemporarily();
      apply(next);
      enable();
    }
    

    Выклік getComputedStyle выглядае як мёртвы код і часта выкарыстоўваецца для яго адчынення. Цэе ўмышленая прымусовая сінхронная перзапіс стылю, якая зафіксавае новыя кольоры, пакуль переходы яшчэ выключаны. Пасля чаго адчыненне коду перакладваецца на пазнейшую задачу за дапамою setTimeout, пасля таго, як перзапіс стылю ваступіў у силу. Без прымусовай перзапісі стылю і адкладання яго адчынення все равно бачыцца частковае затуманенне.

    Якщо вы хочаце, каб змена выглядала б явным эфектам, API View Transitions падтрымлівае звычны кругавы анімацыйны эфект. Ён стаў доступны ў версіі Baseline з 14.10.2025 у Chrome 111, Safari 18 і Firefox 144. Є адна важлівая умова, якую легка прачыніць: трэба выключыць стандартныя анімасці для корэнтных скріншотаў і задаць режым злучэння для старых:

    ::view-transition-old(root) { animation: none; mix-blend-mode: normal; }
    ::view-transition-new(root) { animation: none; }
    

    Таксама трэба вярнавацца да пажаданняў корыстувачаў, якія прасілі менш руху:

    @media (prefers-reduced-motion) {
      ::view-transition-group(*),
      ::view-transition-old(*),
      ::view-transition-new(*) { animation: none !important; }
    }
    

    Не выкасывайце mix-blend-mode: normal з старага скріншота. За стандартным режымам злучэння plus-lighter экран на час стае млечна-белым падчас пераходу з темнага на светлы, што выглядае як баг з віспам і так і фіксуецца.

    Што яшчэ не працуе пасля змены токенаў

    Адпраўкі і SVG

    Элемент <picture> з сэткам media="(prefers-color-scheme: dark)" следуе толькі за настройкама ОС. Ён не можа бачыць ваш атрыбут theme чы сэтк color-scheme, таму ручная зміна настройк прыводзіць да таго, што адпраўкі застаюцца несінхроннымі з рэштой інтерфейса, што ёсць распашчаныя баг у большасці версый. Адпраўкі, заявленыя ў блоке dark token, дзеўальна следуюць за змінамі настройк. Для SVG атрыбут currentColor працюе ў лінейным элементе <svg> і ў структуре <svg><use href="…">, але не для SVG, якія завантажваюцца через <img src> чы CSS-код url(), таму што гэтыя элементы ёсць адзеленымі дакументамі, якія ніколі не спадчыцуюць ваш сэтк color. Лібо падаўце запыты prefers-color-scheme безпасуль у сам файл SVG, лібо выкорыстаўце атрыбут mask-image разам з background-color: currentColor.

    Iframes

    У спецыфікацыях налаштавання кольора зазначаецца, што калі схема кольороў iframe-а не збігаецца з схемай кольороў корневага элемента вбудаванага докумэнта, браузер должен атрыбут Canvas вбудаванага докумэнта запрацоўваць з непразрым полем, а не з празрым. На практыцы гэта стварае белы прякант на темнай сторунцы. Заданне атрыбута color-scheme элементу <iframe> практычна выправляе гэту проблему з першым полем, але CSS власнага докумэнта з іншага кантэксту застаецца недоступным. Сервісы YouTube, Stripe Elements, Disqus і Turnstile кожны пры вбудоўванні адкрываюць окремыя опцыі тэмы; жадных рашэння на рывень CSS не існуе.

    theme-color

    Падтрымка мета-атрыбута theme-color ўсё болей слабая, чым спакушаюць людзі. Firefox не падтрымвае яго ні на якой платформе; версія Chrome для десктопа з 73-й версіі застосоўвае яго толькі да встановленых PWA; Safari узяў яго на адлік у версіі 15, але пачынаючы з Safari 26 — толькі у встановленых веб-дзеяннях. MDN адзначае, што яго доступнасць мерыя, а не ён належыць да базовага статусу. Спефікацыя таксама дазволяе браузерам налагоджваць кольора за сваймі пачуткамі, напрыклад, темаваць яго, каб падтрымаць адпатны контраст, таму не трэба пакладацца на точную візуальную прыказку.

    Прывязаныя кольоры і праграмы контрасту

    У режыме высокага контрасту Windows (forced-colors: active) браузер контролюе такія атрыбуты, як background-color, color, border-color, outline-color, text-decoration-color, а таксама атрыбуты SVG fill і stroke. Атрыбуты box-shadow і text-shadow пераканаліваюцца на none, а color-scheme зафіксаваецца на значэнні light dark. Усе элементы, якія выкарыстоўваюць ценівы для стварэння эфекту глубіны, зьнікнуць, таму іх трэба заменіць на рамкі кольораў системы:

    @media (forced-colors: active) {
      /* your shadow-based elevation is gone — replace it */
      .card { border: 1px solid CanvasText; box-shadow: none; }
      .btn  { border: 1px solid ButtonText; }
    }
    

    Калькі колькаў системы будзе матыць элемент залежыць ад яго натывных семантык HTML, а не ад ролі ARIA; таму элемент <div role="button"> не атрыбутуецца значэннем ButtonText. MDN адзначае, што не трэба ствараць окольны дизайн для режыму прымусовых калераў, а толькі вялікія корэкты для лепшай чытаемасці.

    Іншы аса часта забываецца цэлкам: prefers-contrast, які мае значэння no-preference, more, less і custom. Функцыя Baseline ў шырокай даступнасці з 31.05.2022 і не залежыць ад схемы кольцаў. Тэмы з темным фонам, якія ніколі не выкарыстоўваюць значэнне more, ёсць частым недагэдкам у адпаведнасці да стандартаў доступнасці.

    Проектаванне темнай палітры

    Выкорыстоўвайце темна-сірый колер, а не чорны

    У рэкамендаціях Material для темных фонаў і поверхнаў выбіраецца темна-сірый колер замест чорнага, адтолькі сірый колер дазволяе бачыць цені і зменшае напрэжэнне на воках пад час чытання светлага тексту. У калекцыі кодаў Google для темных тэмаў згадваецца таксама пункт пра колер фону: чысты текст #FFFFFF на темным фоне можа выглядаць розмытым або „вібруючым“, што пагоршвае чытаемасць.

    Заявіце гэта адзёвна. Часта тэза пра тое, што чыстая чорная расклікае «эфект сяйва», не падтрымваная жодным контрольваным даследжэнням. Падтрымваецца толькі эфект крывяння і вібрацыі чыста-белага тэксту, а выбор сярого калеру замест чорнага падтрымваецца можлівасцю бачыць ценю і зменшэнням напроцэсу для вока.

    Выражайце вышыню за дапамою светласі

    Цені на темных тэматыках працуюць паслаба, таму Material компенсуе гэта, робячы поверхні светлейшымі і адно чырвонаватымі, чым вышэй вони знаходзяцца. color-mix() дапамагае лёгкаа адразу стварыць гэта на адной базовай расклікаўцы:

    [data-theme="dark"] {
      --surface-1: #12141a;
      --surface-2: color-mix(in oklab, var(--surface-1) 92%, white);
      --surface-3: color-mix(in oklab, var(--surface-1) 84%, white);
      --surface-4: color-mix(in oklab, var(--surface-1) 76%, white);
    }
    

    color-mix() ўсюды стаў доступны з 2025-11-09. Згадайце, што система elevation-overlay з Material Design 2 ўжо не выкарыстоўвана: у дасягледзе Google сказана, што яе заменіла система кольцаў поверхні, і яе больш не падтрымваюць. У Material 3 выкарыстоўваныя ролі – з surfaceContainerLowest да surfaceContainerHighest, а таксама surfaceDim і surfaceBright. Спрамаванне, якое паказвае старую табліцу з 5 процэнтамі пры 1dp і да 16 процэнтав пры 24dp, апісвае застарелую систему.

    Зніжыць насычанасць акцэнтных кольцаў

    Насыцаныя акцэнты середзінных тона вібруюць на темных паверхнях. OKLCH робіць такія настройкі систематычна: його канал светласці є воспрімаема як аднородны, таму крокі, які ў числовым выражэнні є парныя, таксама выглядаюць парнымі, што самэўсёлкава ў тым, дзе паспявае HSL і чаму темныя градіенты HSL стаюць бурклівымі ў середзіне. Легчы, менш хроматычны акцэнт для темнай тэмы выглядае так:

    :root               { --accent: oklch(0.55 0.18 255); }  /* darker tone on light bg */
    [data-theme="dark"] { --accent: oklch(0.72 0.14 255); }  /* lighter, less chroma */
    

    Паўтарна перапачатка контрасту для темнай тэмы

    Палітра, якая пасходзіць тэст на контраст у светлай модзе, нічога не паведамляе пра темную модзу. Формула вялікасці святла з WCAG 2.x ўстатку короткая, ёю можна скорыстацца ў скрыпті:

    const lum = ([r, g, b]) => {
      const f = v => (v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
      return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
    };
    

    Потым калячанне контрасту дзеліць легкую вялікасць святла на темную, кожная з яых адхіляецца на 0.05:

    const contrast = (a, b) => {
      const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
      return (hi + 0.05) / (lo + 0.05);
    };
    

    Выкананне гэтага тестування для кожнага калеру, які викорыстоўваецца для чытання тексту, дапамагае выявіць проблэмы, якія можа працягнуцься непазорлівым вокам. Узглянем тэму з теплым калерам паперу, дзе ўсё выглядала нормальна на экране: калер амбарылавага кольору мяў співважанне 2,06:1, калер цяплага ружовага кольору, які викорыстоўваецца для выдэлэння ключавых слоў, — 2,80:1, а калер золатага кольору для цифр — 2,50:1. Усе тры калеры былі санкціонаваны вокам, але ўсе тры не паспелі да стандарту AA.

    У формуле WCAG 2.x є вядомая слабасць: яна адночасна спрацоўвае з калерамі светлага на темным і темнага на светлым, таму тэмная палітра можа мець хорашы рэзультат, але ўсё раве выглядаць слепяча. Самэй гэтай слабасці была створана APCA, але APCA не ўключаецца ў WCAG 2.2 і яшчэ не є стандартам нідзе.

    Што кажу даследжэння пра темны режым і чытаемасць

    Гэты момент часта выказваюць адваротна. У кантактным выкладзе даследжэння ад Nielsen Norman Group сказана:

    • Для людзей з нормальным зорам у режыма светлага фона был лепшы результат па ўсіх паказніках у даследаваннях Piepenbrock і інш. (2013, Ergonomics) і Dobres і інш. (2017, Applied Ergonomics). Адчыненне стае ўсё бол значным, калі размер шрыфту зменшаецца. Адчыненне аптічнае: темны текст на светлым фоне стварае больш святла, звужваецца зябра, а меньшая зябра означае меншыя сферычныя аберацыі і большую глęбіну рэзкасці.
    • Для людзей з паўшчым зорам Legge і інш. (1985, Vision Research) выявілі, што всі семеро учаснікаў з запыленым вокальным медыумам, у тым чысле з катарактамі, чыталі быстрей у режыме темнага фона.
    • У далёкай перспективе Aleman і інш. (2018, Scientific Reports) прыпускаюць, што стойкія экспанзія ў режыме светлага фона можа быць супакоўаная з міопіяй через падтонкаванне хорыды.
    • Практычная рэкамендація — дазволіць корыстнікам перайсці на режым темнага фона, якщо яны гэтага жадаюць.

    Справядлівы вывод такі: режым темнага кольору служыць для задоволення персональных праганоў та певных потрэб у доступнасці; ён не спрыяе павышэнню чытаемасці для шырокай аудыторыі. Гэта найсильнейшая аргументацыя за викорыстанне трохоў стаўок замест таго, каб режым темнага кольору быў заданы як стандарт.

    Ключовыя выводы

    • Расследзіце режым темнага кольору як трохоў проблем: выбір тэмы, змена кольераў і застосаванне выбору пры першай нарадзе элементаў.
    • Падберіце назвы токенав па ўпрабаванням, зменіце іх у аднам блоке data-theme і падтрымайце color-scheme у корневым элементе, сінхронізаваўшы яго з блокам data-theme, падтрымленае мета-тагам.
    • Узначыце тэму пры першай нарадзе элементаў, будзь то за дапамогою кукі, чытаемай серверам, або за дапомогою внутрашняго скрыпту, і прыйміце компромісы, якія несе цей скрыпт з точка зору правил CSP.
    • Заставіце тры стаўкі: светлую, темную і системную, і следзіце за змянамі ваўкана толькі калі выбрана стаўка «системная».
  • Якщо ніхто не паўтарае элементы, prefers-color-scheme заместо викорыстання шара токэнаў — гэта найшырэйшая і простая спосаб рашэння.
  • Ніколі не вярцайте filter: invert() на сторанцы, якую вы кантролюеце: гэта ператварае корневы элемент на блок, які ўтримвае кожны фіксаваны элемент.
  • Пераканацеся ў правільнасці контрасту, зображэнняў, іфрамэў і прыменшэння кольораў для темнага шаблона окрема; змена токэнаў не выправляе гэтыя проблемы адразу.
  • Супаўзвязаныя матэрыялы