Всем прекрасного времени суток. Это первая часть из серии двух статей про перенос стилизации с SCSS'а на чистый CSS.

Сегодня мы с вами посмотрим каким образом можно преобразовать миксины SCSS'а на CSS с атомарными классами. Как я уже писал в прошлой статье, я работаю в достаточно молодой компании уровня стартапа, поэтому мы сами открываем методы оптимизации и некоторые особенности CSS'а.

Итак начнём.

С чего всё началось

Нам было необходимо создать функционал в SCSS, который позволит сделать по-настоящему "резиновый" шрифт – при изменении размера экрана динамически меняется размер текста: font-size, line-height. Мой коллега нашёл неплохой способ реализовать это на SCSS через миксин.

@mixin adaptiv-font-engine($maxSize, $minSize, $lineHeightDelta, $maxWidth, $minWidth) {
	$fontCoef: $maxSize - $minSize;
	$widthCoef: $maxWidth - $minWidth;
	$size: calc(#{$minSize}px + #{$fontCoef} * ((100vw - #{$minWidth}px) / #{$widthCoef}));
	
	font-size: $size;
	line-height: calc(#{$size} + #{$lineHeightDelta}px);
}

Дописав этот "движковый" миксин, я сделал центральный миксин, который достаточно долго и использовался в наших проектах.

Неудобный и большой миксин для адаптирования шрифта из "движка":

Полный код миксина с медийными запросами
@mixin adaptiv-font($desktopFont, $laptopFont, $tabletFont, $mobileFont) {
    @include adaptiv-font-engine(
        list.nth($desktopFont, 1),
        list.nth($laptopFont, 1),
        list.nth($desktopFont, 2) - list.nth($desktopFont, 1),
        $desktop-size-max,
        $desktop-size-min
    );

    @include laptop-media() {
        @include adaptiv-font-engine(
            list.nth($laptopFont, 1),
            list.nth($tabletFont, 1),
            list.nth($laptopFont, 2) - list.nth($laptopFont, 1),
            $laptop-size-max,
            $laptop-size-min
        );
    }

    @include tablet-media() {
        @include adaptiv-font-engine(
            list.nth($tabletFont, 1),
            list.nth($mobileFont, 1),
            list.nth($tabletFont, 2) - list.nth($tabletFont, 1),
            $tablet-size-max,
            $tablet-size-min
        );
    }

    @include mobile-media() {
        @include adaptiv-font-engine(
            list.nth($mobileFont, 1),
            list.nth($mobileFont, 1),
            list.nth($mobileFont, 2) - list.nth($mobileFont, 1),
            $mobile-size-max,
            $mobile-size-min
        );
    }
}

Центральный миксин для работы со шрифтами:

/*
Mixin can get 1, 2, 3 and 4 arguments of tuple of font-size and line-height
1 - all
2 - other, mobile
3 - other, tablet, mobile
4 - desctop, laptop, tablet, mobile
*/
@mixin font-size($args...) {
    @if (list.length($args) == 1) {
        @include adaptiv-font(list.nth($args, 1), list.nth($args, 1), list.nth($args, 1), list.nth($args, 1));
    } @else if (list.length($args) == 2) {
        @include adaptiv-font(list.nth($args, 1), list.nth($args, 1), list.nth($args, 1), list.nth($args, 2));
    } @else if (list.length($args) == 3) {
        @include adaptiv-font(list.nth($args, 1), list.nth($args, 1), list.nth($args, 2), list.nth($args, 3));
    } @else if (list.length($args) == 4) {
        @include adaptiv-font(list.nth($args, 1), list.nth($args, 2), list.nth($args, 3), list.nth($args, 4));
    }
}

Пример работы с этим миксином:

@mixin M-Size() {
    @include font-size((20, 26), (18, 22), (16, 20));
}

@mixin M-Medium() {
    font-family: "Roboto-Medium";
    @include M-Size();
}

@mixin M-Regular() {
    font-family: "Roboto-Regular";
    @include M-Size();
}

Первые проблемы

  • Слишком страшный и сложно поддерживаемый код

    • Такое можно понять в силу вшитой адаптивности и логики в "резиновости" самого шрифта

  • Также приходилось в конфигурации проекта глобально импортировать файл, который собирает вышеописанные миксины в одном месте:

    export default defineConfig({
        plugins: [vue(), vueDevTools()],
        css: {
            preprocessorOptions: {
                scss: {
                    additionalData: `
                        @import "@/assets/styles/global.scss";
                    `,
                },
            },
        },
    });
    

    Это мне крайне не нравилось с точки зрения оптимизации CSS-перформанса

К этому добавлялись обновления самого CSS'а, за которыми SCSS'у приходилось следовать. Я говорю о нативном нестинге в CSS, который начали серьёзно обсуждать ещё с 128-го Chrome'а (примерно). Само собой такую технологию в будущем эту технологию поддержали и Safary и Mazilla.

И однажды прийдя на работу и обновив локальный модуль SASS'а я получил огромное полотно варнингов от него. Постоянно была жалоба на так называемый Legacy JS API. Мы смогли избавиться от них добавив в vite.config.js поле api: "modern-compiler" .

export default defineConfig({
    plugins: [vue(), vueDevTools()],
    css: {
        preprocessorOptions: {
            scss: {
                additionalData: `
                    @import "@/assets/styles/global.scss";
                `,
                api: "modern-compiler",
            },
        },
    },
});

Но беда не приходит одна и оказалось, что теперь нельзя использовать миксины посередине стилей, наподобе:

.some-class {
  width: 100px;
  height: 100px;

  @include M-Medium();
  color: current-color;
}

Это рушило логику нативного нестинга уже CSS'а. Для эмитации похожего поведения приходилось ухитряться и писать следующим образом:

.some-class {
  width: 100px;
  height: 100px;

  @include M-Medium();
  
  & {
    color: current-color;  
  }
}

Что было ещё хуже, чем ранее написанные миксины. Было принято решение использовать подобные решения в крайне редких и нетривиальных ситуациях.

Но ...

Через неделю SASS (Dart) выкатил новое обновление, в котором предупреждалось о скором отключении полных импортов из сторонних модулей - разрешается использовать только @use.

Меня в край задолбали такие быстрые и серьёзные изменения, а серверную консоль хочется видеть без варнингов, и пришлось серьёзно взяться за работу.

Переход на CSS

Изначально структура наших модулей была следующей:

sass модули
sass модули

Я принялся переносить все переменные и нормализующие стили на CSS. Но когда столкнулся с миксинами и в особенности с миксином "резинового" размера текста впал в ступор.

Спустя некоторое время я наткнулся на интересную концепцию виртуальных переменных в CSS. Смысл следующий – мы создаём переменную в рамках определённого атомарного класса, которая принимает значение из другой переменной, а также имеет значение по умолчанию на случай отсутствия переменной-аргумента. Для обозначения данной переменной как виртуальной и ограниченной в своём классе мы делаем очень простой модификатор, который используется уже не один год в языках без инкапсуляции классов – добавляем нижнее подчёркивание.

Пример:

.atom-class {
  --_color: var(--color, #FFF);

  color: var(--_color);
}
<span class="my-text atom-class">Hallo, world!!!</span>

Таким образом мы можем контролировать поведение атомарного класса из родного класса тега.

Пример:

.my-text {
  --color: #F00;
}

Таким образом, мы можем создавать аналог миксинов на SCSS используя чистый CSS.

Продолжение идеи

Теперь вернёмся к нашим бараном, из-за которых весь сырбор.

Для более короткого решения приведу пример работы атамарного класса с адаптивным текстом (пока без резиновости):

[class*="static-font"] {
    --_font-size: var(--font-size, 1em);
    --_line-height: var(--line-height, calc(var(--_font-size) + 4px));

    font-size: var(--_font-size);
    line-height: var(--_line-height);
}

.static-font__M {
    --font-size: 20px;
    --line-height: 26px;

    @media (max-width: 1024px) and (min-width: 510px) {
        --font-size: 18px;
        --line-height: 22px;
    }

    @media (max-width: 509px) {
        --font-size: 16px;
        --line-height: 20px;
    }
}

Note: Необходимость в таком нестандартном селекторе [class*="static-font"] обоснована тем, что атомарный класс может располагаться в любом месте аттрибута class. Если же использовать [class^="static-font"], то это будет работать исключительно в случаях когда сам аттрибут начинается на описанную строку – class="static-font__M my-text".

Описанным способом можно создавать атомарные классы для множества стандартных размеров в рамках вашего дизайн-кода.

Для интересующихся оставлю ниже полностью переписанный на CSS вариант резинового текста:

Движок резинового текста
[class*="responsive-font"] {
    /* Require props */
    --_max-font-size: var(--max-font-size);
    --_max-line-height: var(--max-line-height);
    --_max-screen-width: var(--max-screen-width);

    --_min-font-size: var(--min-font-size);
    --_min-line-height: var(--min-line-height);
    --_min-screen-width: var(--min-screen-width);
    /* ============= */

    /* Computed deltas */
    --font-delta: (var(--_max-font-size) - var(--_min-font-size));
    --line-height-delta: (var(--_max-line-height) - var(--_min-line-height));
    --screen-width-delta: (var(--_max-screen-width) - var(--_min-screen-width));
    /* =============== */

    --main-coef: (100vw - var(--_min-screen-width) * 1px) / var(--screen-width-delta);

    /* Target values */
    --computed-font-size: calc(var(--_min-font-size) * 1px + var(--font-delta) * var(--main-coef));
    --computed-line-height: calc(var(--_min-line-height) + var(--line-height-delta) * var(--main-coef));
    /* ============= */

    font-size: clamp(calc(var(--_min-font-size) * 1px), var(--computed-font-size), calc(var(--_max-font-size) * 1px));
    line-height: clamp(calc(var(--_min-line-height) * 1px), var(--computed-line-height), calc(var(--_max-font-size) * 1px));
}

Реализация конкретного размера текста
.responsive-font__M {
    --max-screen-width: var(--desktop-size-max);
    --max-font-size: 20;
    --max-line-height: 26;

    --min-screen-width: var(--tablet-size-max);
    --min-font-size: 18;
    --min-line-height: 22;

    @media (max-width: 1024px) and (min-width: 510px) {
        --max-screen-width: var(--tablet-size-max);
        --max-font-size: 18;
        --max-line-height: 22;

        --min-screen-width: var(--mobile-size-max);
        --min-font-size: 16;
        --min-line-height: 20;
    }

    @media (max-width: 509px) {
        --max-screen-width: var(--mobile-size-max);
        --max-font-size: 16;
        --max-line-height: 20;

        --min-screen-width: var(--mobile-size-min);
        --min-font-size: 16;
        --min-line-height: 20;
    }
}

Переменные размеров экрана
:root {
    
    /* ===== Desktop ===== */
    
    --desktop-size-max: 1920;
    --desktop-size-min: 1441;
    
    /* =================== */
    
    /* ===== Laptop ===== */
    
    --laptop-size-max: 1440;
    --laptop-size-min: 1025;
    
    /* ================== */
    
    /* ===== Tablet ===== */
    
    --tablet-size-max: 1024;
    --tablet-size-min: 510;
    
    /* ================== */
    
    /* ===== Mobile ===== */
    
    --mobile-size-max: 509;
    --mobile-size-min: 350;
    
    /* ================== */
}

К сожалению, логика резинового текста не позволила сделать реализацию короче и легче к прочтению, но данная реализация является менее нагруженной с точки зрения сборки проекта, перформанса самого CSS'а, а также является более удобной в использовании новому члену команды.

Развитие идеи

Мне так понравилась идея виртуальных переменных в CSS, что я решил не останавливаться на достигнутом и создал несколько похожих атомарных классов-миксинов:

.clamp-text-lines {
    --_lines-count: var(--lines-count, 3);

    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: var(--_lines-count);
    overflow: hidden;
}
.custom-scrollbar {
    --_scrollbar-color: var(--scrollbar-color, #3E3E3E);
    --_scrollbar-width: var(--scrollbar-width, 4px);
    scrollbar-gutter: auto;
}

.custom-scrollbar::-webkit-scrollbar {
    width: var(--_scrollbar-width);
    height: var(--_scrollbar-width);
}

.custom-scrollbar::-webkit-scrollbar-thumb {
    border-radius: 100px;
    background-color: var(--_scrollbar-color);
}

.custom-scrollbar::-webkit-scrollbar-track {
    background-color: #0000;
    border-radius: 100px;
}

Заключение

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

А так же гибкость такого подхода подкрепляется нативным (низким) уровнем взаимодействия JS'а с CSS'ом, при необходимости изменить стили находу или по условию - больше никаких классов модификаторов (это, конечно же, тоже в меру).

Анонс

Думаю на следующей неделе выпустить вторую часть данной серии по нативным popover'ам и их анимацией.

Буду рад вашей обратной связи.

Комментарии (15)


  1. delphinpro
    04.12.2024 14:42

    Необходимость в таком нестандартном селекторе [class*="static-font"] обоснована тем

    C ходу не могу понять необходимости селектора атрибута вместо обычного селектора класса. Почему не .static-font { } ?


    1. aleks_andr_19 Автор
      04.12.2024 14:42

      Ниже описан селектор static-font__M, который уже внутри себя имеет реализованные переменные для виртуальных переменных "миксина"


      1. delphinpro
        04.12.2024 14:42

        Вы не ответили на вопрос. Что мешает использовать простой селектор класса для описания "миксина"?


        1. aleks_andr_19 Автор
          04.12.2024 14:42

          Иначе придётся реализовывать в каждом классе типо static-font__XL, static-font__L, static-font__M, static-font__S и тд. :

          --_font-size: var(--font-size, 1em);
          --_line-height: var(--line-height, calc(var(--_font-size) + 4px));
          
          font-size: var(--_font-size);
          line-height: var(--_line-height);

          В кратце - для более лаконичного использования атомарного класса


          1. delphinpro
            04.12.2024 14:42

            1. aleks_andr_19 Автор
              04.12.2024 14:42

              Да, соглашусь. Если вставить два атомарных класса, то это будет работать. Но мне нужно было реализовать это как миксин - то есть через одно имя. Должно работать так:

              <span class="my-text static-font__XS">Hallo, world!!!</span>


              1. delphinpro
                04.12.2024 14:42

                Теперь понятно.


  1. ImagineTables
    04.12.2024 14:42

    сделать по-настоящему "резиновый" шрифт – при изменении размера экрана динамически меняется размер текста

    И такой подход себя оправдывает?

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


    1. aleks_andr_19 Автор
      04.12.2024 14:42

      Соглашусь, дизайнерам не всегда получается вписаться в рамки 10 шрифтов. Но в случае когда надо в конкретном месте подменить некоторые показатели шрифта, это можно легко сделать CSS переменными прямо по селектору тега, на который навесили static-font__M

      В статье есть похожий пример с цветом текста:

      .my-text {
        --color: #F00;
      }


  1. dom1n1k
    04.12.2024 14:42

    Непонятно, зачем все эти сложности с line-height и прибавляемой дельтой вместо простого безразмерного множителя.


    1. aleks_andr_19 Автор
      04.12.2024 14:42

      Соглашусь, зачастую дизайнеры выставляют достаточно строго разницу между font-size и line-height. Но случаются случаи когда необходимо увеличить или наоборот уменьшить line-height:

      Буквально недавно мы разговаривали с нашим лидом дизайна по поводу этого шрифта для кнопок – им было необходимо, чтобы font-size сохранился, но line-height был 26px.

      Относительно применения дельты, которую вы предложили – я пытался применить этот подход, но на практике он оказался неудобным, потому что в Figma line-height указан в конкретном значении и для указания значения дельты нужно делать доп услилие высчитывая её. В данном вопросе я придерживаюсь jobs to be done, чтобы сторонний разработчик просто скопировал значения из Figma и всё работало.


      1. dom1n1k
        04.12.2024 14:42

        зачастую дизайнеры выставляют достаточно строго разницу между font-size и line-height

        Это какой-то домысел, никто разницу не делает.

        В реальности есть два подхода:
        1. Высота строки привязывается к кеглю множителем, например line-height: 1.5;
        2. Высота строки фиксируется в конкретном значении, например line-height: 24px; – в частности, этот вариант полезен, когда высота строки должна быть кратна какой-то сетке, например 8-пиксельной. Но это не фиксированная дельта.


        1. aleks_andr_19 Автор
          04.12.2024 14:42

          Под строгим выставлением дельты я имею ввиду, что в фигме явно выставлен и `font-size` и `line-height` – как раз второй вариант, про который вы говорили


          1. dom1n1k
            04.12.2024 14:42

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


            1. aleks_andr_19 Автор
              04.12.2024 14:42

              SASS’овский миксин «резинового» текста был сделан на скорую руку и я не проводил тщательного ревью (я тимлид), поэтому решили оставить так