Приветствую тебя, мой дорогой друг, сегодня немного поколдуем с JavaScript, CSS и HTML. Ссылка на заголовок

В рамках одного из заказов, а также ввиду того что я оптимизирую вывод Doxygen, понадобился небольшой легковесный скрипт для переключения тем, и, собственно как всегда - встал вопрос о том что именно делать или не делать. Вариантов решения таких задач масса, самый простой - найти в Яндекс, Google или напрямую в GitHub, но, тут есть несколько нюансов:

  • Я только начинаю свою официальную карьеру программиста и намерен полностью восстановить свои навыки программирования на всех языках которыми раньше пользовался(не смотря на то что прошло всего пара лет, считаю что такими вещами нужно заниматься регулярно), тем более что JavaScript априори должен быть в арсенали фронтэндера.
  • Как правилло, библиотеки с подобными инструментами весят достаточно много, а мне хотелось бы поменьше нагружать свой сервер и браузеры посетителей сайта моего заказчика.
  • Если просто вставить скрипт не заглянув ему под капот, то очень легко можно отхватить какой-нибудь эксплоит который вынесет ваш сайт за 5 минут или скрипт с утечкой памяти что может крайне негативно сказаться на пользовательском опыте в результате чего посетитель может и не вернуться.
  • Ну и наконец - моя больная тяга делать всё своими руками, времени сегодня у меня вагон и маленькая тележка, так что могу себе позволить потратить 3-4 часа на данную плюшку.

Что нам понадобится Ссылка на заголовок

Понадобится нам совсем не много: 1 div элемент приправленный простейшим текстовым SVG(векторное изображение), прикрученный к сайту небольшим JavaScript-ом и для пущей надёжности приклееный на синюю изоленту - CSS.

ТЗ Ссылка на заголовок

Трансформируемся в Architect_lvl80 и начинаем клацать пальцами по клаве:

API(интерфейс) Ссылка на заголовок

  • переключление на следующую тему:
  • установить тему напрямую.

UI(гуюзер интерфейс) Ссылка на заголовок

  • переключение тем должно выполняться не мешающей или дополняющей дизайн кнопкой(такойже как у внизу справа).

Функцирнал Ссылка на заголовок

  • темы должны меняться циклично, после последней - снова первая(1-2-3-1-2-3);
  • если пользователь не выбирает тему сам она должна быть установлена системно(тёмная-светлая);
  • выбранная тема должна сохраняться;
  • переключение должно быть плавным.

У-у-ух! Ничёсе, отошёл на 5 минут ребёнка укачать и 3 часа сладенько проспал…

Так-так-так… На чём же я остановился-то… Тощно-тощно, пора реализовывать!

С чего начать Ссылка на заголовок

Для начала нам понадобится подготовить то над чем мы будем издеваться - CSS. Итак, вспоминаем что это за зверь такой - СЫЫ, берём любую первую попавшуюся страничку(желательно с текстом, заголовками и ссылками) и переходим в режим Mega-pro-designer_lvl100500. Открываем любимую IDE, создаём 3 файла(index.html, main.css, main.js). У меня имеется дефолтная страничка от Hugo, но можно использовать асолютно любую, на всякий случай(чтоб вам долго не искать) вот код(к стати, я реализовал для Doxygen кнопку копирования, так что когда нибудь она и тут появится, сейчас не до её внедрения 😅):

Простенькая страничка, ничего особенного, вставлена как есть, по читабельности вопросы к gohugo.io...
<!DOCTYPE html>
<html lang="ru-RU" dir="ltr">
<head>
	<meta name="generator" content="Hugo 0.147.9">
    <script src="/livereload.js?mindelay=10&amp;v=2&amp;port=1313&amp;path=livereload" data-no-instant defer></script>
  <meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>Подопытный</title>
    <link rel="stylesheet" href="main.css">
      <script src="main.js"></script>
</head>
<body>
  <header>
    <!-- Элемент хранящий в себе векторное изображение которое будет использоваться как содержимое кнопки переключения тем -->
    <div id="svg-toggle-theme">
    <svg
        width="inherit"
        height="inherit"
        viewBox="0 0 135.46666 135.46667"
        version="1.1"
        id="svg1"
        xmlns="http://www.w3.org/2000/svg"
        xmlns:svg="http://www.w3.org/2000/svg">
        <path
            id="path1"
            style="opacity:inherit;fill:currentColor;fill-opacity:1;stroke:#171717;stroke-width:0"
            d="M 67.777258,12.777515 A 55,55 0 0 0 12.777515,67.777258 55,55 0 0 0 67.777258,122.77752 55,55 0 0 0 122.77752,67.777258 55,55 0 0 0 67.777258,12.777515 Z m -0.128157,4.95577 V 117.73338 A 50,50 0 0 1 17.733285,67.733333 50,50 0 0 1 67.649101,17.733285 Z" />
    </svg>
    </div>
<h1>Не бойся что-нибудь сломать, бойся это не исправить! 😂</h1>

  <nav>
    <ul>
    <li>
      <a aria-current="page" class="active" href="/">Типа Главная</a>
    </li>
    <li>
      <a href="/posts/">Типа Блог</a>
    </li>
    </ul>
  </nav>


  </header>
  <main>
    
  <p>Laborum voluptate pariatur ex culpa magna nostrud est incididunt fugiat
pariatur do dolor ipsum enim. Consequat tempor do dolor eu. Non id id anim anim
excepteur excepteur pariatur nostrud qui irure ullamco.</p>

  
    <h2><a href="/posts/post-3/">Лучше не жмакай</a></h2>
    <p>Occaecat aliqua consequat laborum ut ex aute aliqua culpa quis irure esse magna dolore quis. Proident fugiat labore eu laboris officia Lorem enim. Ipsum occaecat cillum ut tempor id sint aliqua incididunt nisi incididunt reprehenderit. Voluptate ad minim sint est aute aliquip esse occaecat tempor officia qui sunt. Aute ex ipsum id ut in est velit est laborum incididunt. Aliqua qui id do esse sunt eiusmod id deserunt eu nostrud aute sit ipsum. Deserunt esse cillum Lorem non magna adipisicing mollit amet consequat.</p>
  
    <h2><a href="/posts/post-2/">Сюда тоже</a></h2>
    <p>Anim eiusmod irure incididunt sint cupidatat. Incididunt irure irure irure nisi ipsum do ut quis fugiat consectetur proident cupidatat incididunt cillum. Dolore voluptate occaecat qui mollit laborum ullamco et. Ipsum laboris officia anim laboris culpa eiusmod ex magna ex cupidatat anim ipsum aute. Mollit aliquip occaecat qui sunt velit ut cupidatat reprehenderit enim sunt laborum. Velit veniam in officia nulla adipisicing ut duis officia.</p>
<p>Exercitation voluptate irure in irure tempor mollit Lorem nostrud ad officia. Velit id fugiat occaecat do tempor. Sit officia Lorem aliquip eu deserunt consectetur. Aute proident deserunt in nulla aliquip dolore ipsum Lorem ut cupidatat consectetur sit sint laborum. Esse cupidatat sit sint sunt tempor exercitation deserunt. Labore dolor duis laborum est do nisi ut veniam dolor et nostrud nostrud.</p>
  
    <h2><a href="/posts/post-1/">Про эту вообще забудь!</a></h2>
    <p>Tempor proident minim aliquip reprehenderit dolor et ad anim Lorem duis sint eiusmod. Labore ut ea duis dolor. Incididunt consectetur proident qui occaecat incididunt do nisi Lorem. Tempor do laborum elit laboris excepteur eiusmod do. Eiusmod nisi excepteur ut amet pariatur adipisicing Lorem.</p>
<p>Occaecat nulla excepteur dolore excepteur duis eiusmod ullamco officia anim in voluptate ea occaecat officia. Cillum sint esse velit ea officia minim fugiat. Elit ea esse id aliquip pariatur cupidatat id duis minim incididunt ea ea. Anim ut duis sunt nisi. Culpa cillum sit voluptate voluptate eiusmod dolor. Enim nisi Lorem ipsum irure est excepteur voluptate eu in enim nisi. Nostrud ipsum Lorem anim sint labore consequat do.</p>
  

  </main>
  <footer>
    <p>Copyright 2026. All rights reserved.</p>

  </footer>
</body>
</html>

Создание тем оформления Ссылка на заголовок

Открываем файл main.css и решаем задачу №1: обеспечение наличия светлой и тёмной темы, легко, даже не вспотеем. В CSS есть для этого всё необходимое:

  • Переменные: очень мощный инструмент который позволяет изменив одну переменную автоматически применить изменения ко всем селекторам использующим её в качестве значения. Синтаксис весьма прост и понятен, начинаем с двух тире и за ними любой буквенный ANSI-символ, нижнее подчёркивание или дефис, обратите внимание что названия чувствительны к регистру(--This_Is_My_CSS-Var это не одно и тоже с --this_is_my_css-var).
  • Определённый метатег который позволяет браузеру самому выбирать как и что ему показывать на основе настроек операционной системы и называется он @media.
  • Также есть определённое свойство-фича которое называется prefers-color-scheme(грубо говоря - тема по умлчанию в ОС), как правило имеет 2 значения(по крайней мере о которых я пока ещё помню) это без квалификатора и с квалификатором : dark.

Итак, в папке с файлом index.html создаём файл main.css и напихиваем в него нужные нам переменные:

/* Объявляем переменные для всего документа (:root - document.documentElement.style) */
:root {
    --bg-color: #ddd;   /* цвет фона */
    --text-color: #222; /* цвет текста */
    --accent-color: #007bff;    /* цвет подсветки */
    --border-color: #aaa;       /* цвет рамки */
    --shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* тень от элемента */
    --font-family: 'Arial', sans-serif;       /* название шрифта */
    --text-shadow: 0 2px 4px rgba(150, 150, 150, 0.3);  /* тень от текста */
}

/* Тёмная тема через системные настройки ОС */
@media (prefers-color-scheme: dark) {
  :root {
    --bg-color: #222;   /* цвет фона */
    --text-color: #ddd; /* цвет текста */
    --accent-color: #40a9ff;    /* цвет подсветки */
    --border-color: #444;       /* цвет рамки */
    --text-shadow: 0 2px 4px rgba(150, 150, 150, 0.3); /* тень от элемента */
    --font-family: 'Arial', sans-serif;       /* название шрифта */
  }
}

Это, скажем так, установки по умолчанию для светлой и тёмной темы, далее как обычно определяем внешний стиль для тем но с использованием этих переменных директивой var:

body {
    background-color: var(--bg-color); /* цвет фщна */
    color: var(--text-color); /* цвет текста */
    transition: background-color 0.3s ease, color 0.3s ease; /* время анимации перехода */
    text-shadow: var(--text-shadow); /* тень от текста */
    font-family: var(--font-family); /* название шрифта */
    max-width: 756px;   /* максимальная ширина элемента */
    margin: auto;       /* отступ */
}
a {
    color: var(--text-color); /* цвет текста ссылки */
    transition: background-color 0.3s ease, color 0.3s ease; /* анимация изменения */
    text-shadow: var(--text-shadow); /* тень от текста */
    text-decoration: underline; /* подчёркивание */
    font-family: var(--font-family); /* название шрифт */
    font-weight: bold; /**/
}
.button {
    background-color: var(--accent-color); /* цвет кнопки */
    border: 1px solid var(--border-color); /* рамка вокруг кнопки */
    padding: 0.5rem 1rem; /* внуренний отсуп */
    border-radius: 4px; /* радиус закругления углов */
    font-family: var(--font-family); /* название шрифта */
}

Чтож, посмотрим на сколько хорошо сработал режим Mega-pro-designer_lvl100500, открываем в браузере сохранённый файл index.html и радуемся:

  • Светлая: Dark_theme
  • Тёмная: Dark_theme

Ай да молодец живущий внутри нас дизайнер, постарался на славу, спасибо ему, пускай возьмёт с полки приожок, там их два, пусть берёт срений… 🤭

Полное содержание файла main.css
/* Базовые переменные (светлая тема по умолчанию) */
:root {
    --bg-color: #ddd;
    --text-color: #222;
    --accent-color: #007bff;
    --border-color: #aaa;
    --shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    --font-family: 'Arial', sans-serif;
    --text-shadow: 0 2px 4px rgba(150, 150, 150, 0.3);
}

/* Тёмная тема через системные настройки ОС */
@media (prefers-color-scheme: dark) {
  :root {
    --bg-color: #222;
    --text-color: #ddd;
    --accent-color: #40a9ff;
    --border-color: #444;
    --text-shadow: 0 2px 4px rgba(150, 150, 150, 0.3);
    --font-family: 'Arial', sans-serif;
  }
}

.color-sheme-dark {
    --bg-color: #222;
    --text-color: #ddd;
    --accent-color: #40a9ff;
    --border-color: #444;
    --text-shadow: 0 2px 4px rgba(150, 150, 150, 0.3);
    --font-family: 'Arial', sans-serif;
}
.color-sheme-light {
    --bg-color: #ddd;
    --text-color: #222;
    --accent-color: #007bff;
    --border-color: #aaa;
    --text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    --font-family: 'Arial', sans-serif;
}
.color-sheme-terminal {
    --bg-color: #222;
    --text-color: #9d9;
    --accent-color: #40a9ff;
    --border-color: #444;
    --text-shadow: 0px 0px 10px rgba(255, 255, 0, 0.9);
    --font-family: Monospace;
}

body {
    background-color: var(--bg-color);
    color: var(--text-color);
    transition: background-color 0.3s ease, color 0.3s ease;
    text-shadow: var(--text-shadow);
    font-family: var(--font-family);
}
a {
    color: var(--text-color);
    transition: background-color 0.3s ease, color 0.3s ease;
    text-shadow: var(--text-shadow);
    text-decoration: underline;
    font-family: var(--font-family);
}
.button {
    background-color: var(--accent-color);
    border: 1px solid var(--border-color);
    padding: 0.5rem 1rem;
    color: var(--text-color);
    border-radius: 4px;
    font-family: var(--font-family);
    transition: background-color 0.3s ease, color 0.3s ease;
}

#svg-toggle-theme {
    position: fixed;
    width: 20px;
    height: 20px;
    border-radius: 10px;
    right: 15px;
    top: 15px;
    filter: drop-shadow(var(--text-shadow));
    cursor: move;
}
#svg-toggle-theme.dragging {
  opacity: 0.8; /* визуальная обратная связь */
  transform: scale(1.1); /* небольшое увеличение при перетаскивании */
}

Программирование поведения(JavaScript) Ссылка на заголовок

С оформлением разобрались, теперь настало время поговорить о самом скрипте.

Для написания не требуется особой подкотовки, хотя основные аспекты стоит изучить, к примеру - тут.

Нам необходимо создать класс Theme, со следующим интерфейсом(публичными методами):

  • constructor(): конструктор объекта класса который автоматически вызывается при использовании оператора new;
  • set(theme): устанавливать конкретную тему по её имени 'auto', 'light', 'dark';
  • toggle(): переключает темы “по кругу”(1-2-3-1-…).

Для корректной и быстрой работы класса нам потребуются некоторые переменные(поля/свойства) которые будут хранить текущую тему, ссылку на элемент к которому она применяется, названия тем, префикс класса, идентификатор в хранилище:

  • Статичные(для всех объектов, static):

    • #STORAGE: будет хранить в себе ключ хранилища(идентификатор);
    • #NAME: массив названий тем, первыми тремя всегда должна быть:
      • [0] => 'auto';
      • [1] => 'light';
      • [2] => 'dark';
    • #AUTO: это индекс темы по умолчанию(системной), далее будет объяснено зачем;
    • #CLASS_PREFIX: это префикс(приставка) названия класса темы.
  • Поля объекта свойства отдельного экземпляра:

    • #current: текущая тема для конкретного элемента;
    • #element: ссылка на элемент к которому применена тема.

Особо наблюдательные уже, наверное, заметили недостающий элемент - изображение кнопки которое сохранено в формате .svg(векторная графика) в шаблоне index.thml, на всякий случай код приведён ниже:

    <!-- Элемент хранящий в себе векторное изображение которое будет использоваться как содержимое кнопки переключения тем -->
    <div id="svg-toggle-theme">
      <svg width="100%" height="100%" viewBox="0 0 135.46666 135.46667" version="1.1" id="button-toggle-theme-svg" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
        <path id="path1" style="opacity:inherit;fill:currentColor;fill-opacity:1;stroke:#171717;stroke-width:0" d="M 67.777258,12.777515 A 55,55 0 0 0 12.777515,67.777258 55,55 0 0 0 67.777258,122.77752 55,55 0 0 0 122.77752,67.777258 55,55 0 0 0 67.777258,12.777515 Z m -0.128157,4.95577 V 117.73338 A 50,50 0 0 1 17.733285,67.733333 50,50 0 0 1 67.649101,17.733285 Z"/></svg>
    </div>

Чего особенного в данном изображении:

  • основной цвет настроен как currentColor(цвет текста);
  • прозрачность наследуется от роителя(opacity:inherit);
  • размер заполняет всё доступное пространство родительского элемента.

С этим всё понятно, вроде, перейдём к самому написаню Ссылка на заголовок

Пререключение на следующую тему Ссылка на заголовок

Итак для корректной обработки переключения тем нам понадобится не строковое название тем а их индекс, хотя можно конечно обойтись и без них, но ввиду того что я закоренелый С-шник немного поизвращаюсь. В чём суть сего извращения: дань парочке мощных операторов в программировании вцелом - взятию остатка от деления(mod(): ‘%’) и тернарному оператору ([условие] ? [если истина] : [если ложь]). Поскольку автоматическая тема не участвует в переключении можем записать всё в одну длинную строчку:

this.#set(this.#current ? ((this.#current % (Theme.#NAME.length - 1)) + 1) : (1 + (window.matchMedia('(prefers-color-scheme: dark)').matches ? 0 : 1)));

Если текущая тема не системна(this.#current не равно (0 или null)) тогда вычисляем следующую тему по схеме:

((this.#current % (Theme.#NAME.length - 1)) + 1)
  • вычитаем из размера массива Theme.#NAME единицу;
  • берём остаток от деления this.#current на получившееся число;
  • прибавляем к остаттку единицу

В противном случае нас ждёт ещё один тернарный оператор который определяет текущую системную тему 0(светлая) или 1(темная) и прибавляет к резльтату единицу, т.к. у светлой темы индекс 1, у тёмной - 2:

(1 + (window.matchMedia('(prefers-color-scheme: dark)').matches ? 0 : 1))

Таким образом какая бы системная тема ни была(светлая или тёмная) скрипт всегда переключит на противоположную, это нужн для отзывчивости интерфейса, нажав кнопку пользователь ощутит изменения сразу.

Всю семмантику работы с переключением тем можно упростить, просто нужно немножечко подумать. Я сделал именно так чтобы добавление новых тем было максимально простым:

  • добавляем в конец массива Theme.#NAME название нашей темы;
  • создаём селектор в main.css и заполняем его.
Установка выбранной темы Ссылка на заголовок

Установкой выбранной темы занимаются два метода Theme.set внешний set(theme) и внутренний #set(theme). Внешний работает со строковым значением, внтренний с индексом темы в массиве Theme.#NAME. Theme.set(theme): проверяет корректность переданного аргумента, если таковой находится в масиве имён - применяет вызовом Theme.#set(idx). Theme.#set(theme): удаляет класс текущей темы, после - изменяет текущий идентификатор #current, затем - добавляет класс выбранной темы.

Полное содержание файла main.js
/** Статические поля служат своеобразным конфигом */
class Theme {
    static #STORAGE = 'color-sheme';  /// 'color-sheme' - это не ошибка, а умеышленный пропуск 'c', цель - изолировать данные для предотвращения возможных конфликтов с другими скриптами
    static #NAME = ['auto', 'light', 'dark', 'terminal'];   /// Массив с именами тем
    static #AUTO = 0;   /// Тема ОС
    static #CLASS_PREFIX = 'color-sheme-';  /// Префикс названия класса. 'sheme' - также как и со #STORAGE
    static #BUTTON_SVG_ID = 'svg-toggle-theme';

    #current = 0;
    #element = null;

    constructor() {
        this.#element = document.documentElement;
        this.#load();
    }
    #load() {
        this.#current = parseInt(localStorage.getItem(Theme.#STORAGE)) || Theme.#AUTO;
        this.#element.classList.add(`${Theme.#CLASS_PREFIX}${Theme.#NAME[this.#current]}`);
    }
    /**
     * @param {number} theme 
     */
    #set(theme) {
        if (theme != this.#current) {
            this.#element.classList.remove(`${Theme.#CLASS_PREFIX}${Theme.#NAME[this.#current]}`);
            // Добавляем новый класс
            this.#current = theme;
            this.#element.classList.add(`${Theme.#CLASS_PREFIX}${Theme.#NAME[this.#current]}`);
            localStorage.setItem(Theme.#STORAGE, this.#current);
        }
    }
    /**
     * @brief 
     * @param {string} theme 
     * @returns {void}
     * @throws {Error}
     */
    set(theme) {
        const idx = Theme.#NAME.indexOf(theme);
        if (idx == -1) {
            throw new Error('Invalid color sсheme.');
        }
        this.#set(idx);
    }
    toggle() {
        this.#set(this.#current ? ((this.#current % (Theme.#NAME.length - 1)) + 1) : (1 + (window.matchMedia('(prefers-color-scheme: dark)').matches ? 0 : 1)));
        console.log(`Current theme: ${Theme.#NAME[this.#current]}`);
    }
};

let theme = null;
let themeToggle = null;

function theme_init() {
    if (!theme) {
        theme = new Theme();
    }
    document.getElementById(Theme.#BUTTON_SVG_ID).onclick = () => {
        theme.toggle();
    };
    window.removeEventListener('load', theme_init);
}

(function () {
    window.addEventListener('load', theme_init);
}());

Итог Ссылка на заголовок

Вот собственно и всё о чём хотелось рассказать. Этот скрипт можно оптимизировать, но я намеренно дал именно такую форму вот почему:

  • побуждение к размышлению;
  • прикладывание рук в нужное место;
  • закрепление темы алгоритмов.

Могу дать лишь небольшую подсказку:

  • для автоматической темы не нужно указывать класс, браузер выберёт её самостоятельно через @media.
  • для упрощения алгоритма переключения нужно будет немного изменить массив Theme.#NAME.

В файлах умышленно отсутствует меню выбора темы, предлагаю Вам реализовать его самостоятельно. Я в Вас верю! Удачи всем, любви и обнимашек! 🤭