Команда JavaScript for Devs подготовила перевод статьи о том, как CSS и HTML могут заменить значительную часть JavaScript. Автор делится взглядом на веб как на искусство, показывает возможности современных фич CSS — от вложенности и @starting-style до динамических viewport-единиц, — и доказывает, что сайты могут быть быстрыми, красивыми и интерактивными даже без JS.


Комментарий от редакции JavaScript for Devs

Автор в статье использует множество практических приёмов, показывая возможности CSS: некоторые рисунки и эффекты реализованы прямо кодом. Habr не поддерживает вставку всех таких элементов, поэтому то, что вы видите как картинка — чаще всего CSS/HTML-реализация, а другие элементы — интерактивны. Хотите поэкспериментировать с ними — переходите в оригинальную статью.

Сегодняшний веб во многом испорчен раздувшимися современными JavaScript-фреймворками. React-приложения, которые грузятся по несколько секунд. Сайты на NextJS, сыплющие случайные ошибки. Директория node_modules, раздутая до гигабайтов на вашем диске.

Это ужасно. И вам всё это не нужно.

Вступление этой статьи — нарочно преувеличенное. Оно нужно, чтобы вы прочитали текст дальше. Думаю, настоящая причина тормозов — мегабайты трекинговых скриптов, перемешанные с плохим кодом. У веб-фреймворков есть своё время и место. И несмотря на мою личную неприязнь к ним, я понимаю, что многие команды создают с их помощью отличные, хорошо оптимизированные приложения.

Тем не менее в том, чтобы отказаться от всего этого, есть особая прелесть. Не только от фреймворков, но и от JavaScript в целом. Не каждому сайту он нужен. Да, вашему интернет-магазину он может понадобиться для сложной корзины и дашбордов с визуализацией данных, но правда ли он необходим на большинстве сайтов?

HTML и CSS сами по себе способны на удивительно многое.

И что вы на это скажете?

Моя цель в этой статье — поделиться своим взглядом на веб и показать вам многие возможности современного HTML и CSS, о которых вы, возможно, не знали. Я не пытаюсь убедить вас отказаться от JavaScript — я просто хочу показать, что сегодня возможно, а вы уже сами решите, что лучше подходит вашему проекту.

Мне кажется, большинство веб-разработчиков знают о CSS далеко не всё.

И я думаю, что JS часто используют там, где есть лучшие альтернативы.

Так что давайте посмотрим, что у нас есть.

«Но CSS же отстой»

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

Похоже, это связано с тем, что CSS часто воспринимают как нелепое дополнение для того, чтобы рисовать рамки и тени в веб-приложении. Его недооценивают и сравнивают с детскими карандашами, хотя на самом деле это мощный специализированный язык программирования.

Показательно, что до сих пор единственная шутка про CSS в веб-разработке — это «как центрировать div».

Да, синтаксис не самый красивый, но разве это действительно так сложно?

К тому же, в ваших devtools, скорее всего, есть удобный инструмент, где можно настраивать flexbox кликами мыши, даже не вспоминая синтаксис.

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

«Но писать же на CSS больно»

Ещё одна причина нелюбви к CSS — это то, насколько ужасно было писать его раньше. Это действительно так и, вероятно, именно поэтому появились такие вещи, как Sass и Tailwind.

Но суть в том, что раньше было плохо.

Код на изображении соответствует стандартам
Код на изображении соответствует стандартам

За последние несколько лет CSS получил массу крутых улучшений, которые сделали его гораздо удобнее. Теперь можно делать то, что раньше требовало препроцессоров или JavaScript.

Вложенность — определённо одно из моих любимых нововведений!

Раньше приходилось писать что-то вроде этого:

:root {
  --like-color: #24A4F3;
  --like-color-hover: #54B8F5;
  --like-color-active: #0A6BA8;
}

.post {
  display: block;
  background: #EEE;
  color: #111;
}

.post .avatar {
  width: 48px;
  height: 48px;
}

.post > .buttons {
  display: flex;
}

.post > .buttons .label {
  font-size: 24px;
  padding: 8px;
}

.post > .buttons .like {
  cursor: pointer;
  color: var(--like-color);
}

.post > .buttons .like:hover {
  color: var(--like-color-hover);
}

.post > .buttons .like:active {
  color: var(--like-color-active);
}

@media screen (max-width: 800px) {
  .post > .buttons .label {
    font-size: 16px;
    padding: 4px;
  }
}

@media (prefers-color-scheme: dark) {
  .post {
    background: #222;
    color: #FFF;
  }
}

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

А теперь попробуем с вложенностью:

:root {
  --like-color: #24A4F3;
  --like-color-hover: hsl(from var(--like-color) h s calc(l + 10));
  --like-color-active: hsl(from var(--like-color) h s calc(l - 20));
}

.post {
  display: block;
  background: #EEE;
  color: #111;
  @media (prefers-color-scheme: dark) {
    background: #222;
    color: #FFF;
  }
  .avatar {
    width: 48px;
    height: 48px;
  }
  & > .buttons {
    display: flex;
    .label {
      font-size: 24px;
      padding: 8px;
      @media (width <= 800px) {
        font-size: 16px;
        padding: 4px;
      }
    }
    .like {
      cursor: pointer;
      color: var(--like-color);
      &:hover { color: var(--like-color-hover); }
      &:active { color: var(--like-color-active); }
    }
  }
}

Это же гораздо приятнее читать! Все связанные части кода находятся рядом, и сразу понятно, что к чему. Особенно здорово видеть &:hover и &:active прямо рядом с .like — лично мне это очень нравится.

Плюс теперь видна сама структура — родительские селекторы как бы «охраняют» дочерние. Это позволяет безболезненно использовать короткие и простые имена классов (или даже обращаться к самим элементам напрямую).

Вы, наверное, заметили, что во втором примере я использую относительные цвета. В статье на MDN есть отличные примеры, но суть в том, что можно взять существующий цвет, изменить его множеством способов в разных цветовых пространствах и даже смешивать цвета с помощью color-mix().

/* убрать синий из цвета */
rgb(from #123456 r g 0);
/* сделать цвет полупрозрачным */
rgb(from #123456 r g b / 0.5);
/* сделать цвет светлее */
hsl(from #123456 h s calc(l + 10));
/* изменить тон в цветовом пространстве oklch */
oklch(from #123456 l c calc(h + 10));
/* смешать два цвета в цветовом пространстве oklab */
color-mix(in oklab, #8CFFDB, #04593B 25%);

Эти сниппеты невероятно полезны, когда вам нужно сделать что-то чуть-чуть темнее или светлее — например, для эффекта наведения на кнопку или подходящей рамки. И пользоваться ими куда приятнее, чем вручную гонять цвета через преобразования в JavaScript. А если вы настроены поэкспериментировать, то можете вообще сгенерировать всю цветовую схему прямо в CSS.

Color picker написан только на CSS
Color picker написан только на CSS
Исходный код
/* This is editable ^_^ */
@property --cqw {
  syntax: '<length>';
  inherits: true;
  initial-value: 1cqw;
}
@property --cqh {
  syntax: '<length>';
  inherits: true;
  initial-value: 1cqh;
}
@property --vw {
  syntax: '<length>';
  inherits: true;
  initial-value: 100vw;
}
@property --vh {
  syntax: '<length>';
  inherits: true;
  initial-value: 100vh;
}
@property --svh {
  syntax: '<length>';
  inherits: true;
  initial-value: 100svh;
}
@property --dvh {
  syntax: '<length>';
  inherits: true;
  initial-value: 100dvh;
}
@property --lvh {
  syntax: '<length>';
  inherits: true;
  initial-value: 100lvh;
}
/* oklch easter egg */
color-demo:has(color-style:active):not(:has(color-swatch:active)) {
  color-bg {
    background-image: linear-gradient(in oklch to right,
      oklch(75% 100% 0),
      oklch(75% 100% 120deg),
      oklch(75% 100% 240deg),
      oklch(75% 100% 360deg)
    ), linear-gradient(#000, #FFF);
  }
  color-result>div {
    --picked-color: oklch(calc(tan(atan2(var(--cqh), 1px)) / calc(var(--h) / 200)) 100% calc(tan(atan2(var(--cqw), 1px)) / calc(var(--w) / 720)));
  }
}
color-demo, color-picker, color-point, color-result, color-bg {
  display: block;
}
color-demo {
  display: grid;
  grid-template-rows: 1fr;
  grid-template-columns: 1fr;
  container-type: size;
  /*--w: 720;*/
  /*--h: 400;*/
  --w: min(calc(tan(atan2(var(--vw), 1px)) - 48), 768);
  --h: 300;
  width: calc(var(--w) * 1px);
  height:calc(var(--h) * 1px + 324px);
  border-radius: 6px;
  background: #FFF;
  overflow: clip;
  /*
  background:  linear-gradient(in hsl to right,
    hsl(0 100% 50%),
    hsl(120deg 100% 50%),
    hsl(240deg 100% 50%),
    hsl(360deg 100% 50%)
  ), linear-gradient(#000, #FFF);
  background-size: calc(100% - 16px) calc(100% - 16px);
  background-position: center;
  background-repeat: no-repeat;
  background-blend-mode: overlay;
  */
  position: relative;
  color-bg {
    color-scheme: only light;
    grid-area: 1 / 1;
    width: calc(100% - 16px);
    height: calc(var(--h) * 1px - 16px);
    margin: 8px;
    border-radius: 4px;
    background:  linear-gradient(in hsl to right,
      hsl(0 100% 50%),
      hsl(120deg 100% 50%),
      hsl(240deg 100% 50%),
      hsl(360deg 100% 50%)
    ), linear-gradient(#000, #FFF);
    background-position: center;
    background-repeat: no-repeat;
    background-blend-mode: overlay;
    box-shadow: 1px 1px 5px 0 #0002;
  }
  color-picker {
    color-scheme: only light;
    display: grid;
    grid-area: 1 / 1;
    position: relative;
    grid-template-columns: 1fr;
    grid-template-rows: 1fr;
    width:fit-content;
    height:fit-content;
    &>*{
      grid-column: 1; grid-row: 1;
    }
    color-indicator {
      --sw1: 0;
      --sw2: 0;
      --sw3: 0;
      --sw4: 0;
      --sw5: 0;
      --sw6: 0;
      --sw7: 0;
      --sw8: 0;
      --sw9: 0;
    }
    &:has(>color-result color-swatch > :nth-child(1):hover) color-indicator { --sw1: 2px }
    &:has(>color-result color-swatch > :nth-child(2):hover) color-indicator { --sw2: 2px }
    &:has(>color-result color-swatch > :nth-child(3):hover) color-indicator { --sw3: 2px }
    &:has(>color-result color-swatch > :nth-child(4):hover) color-indicator { --sw4: 2px }
    &:has(>color-result color-swatch > :nth-child(5):hover) color-indicator { --sw5: 2px }
    &:has(>color-result color-swatch > :nth-child(6):hover) color-indicator { --sw6: 2px }
    &:has(>color-result color-swatch > :nth-child(7):hover) color-indicator { --sw7: 2px }
    &:has(>color-result color-swatch > :nth-child(8):hover) color-indicator { --sw8: 2px }
    &:has(>color-result color-swatch > :nth-child(9):hover) color-indicator { --sw9: 2px }
    &:has(>color-result color-swatch[analogous]:hover) color-indicator {
      /*filter: drop-shadow(37px 0px 0px #EEE) drop-shadow(0px 20px 0px #EEE);*/
      box-shadow: 0 0 1px 1px #0005 inset, 0 0 0px 1px #0008 inset,
                  0 0 0 var(--sw3) #000,
                  calc(var(--w) * -.111px) 0px 0px var(--sw1) #A4F9,
                  calc(var(--w) * -.055px) 0px 0px var(--sw2) #C2F9,
                  calc(var(--w) * 0.055px) 0px 0px var(--sw4) #F2C9,
                  calc(var(--w) * 0.111px) 0px 0px var(--sw5) #F4A9;
    }
    &:has(>color-result color-swatch[primary]:hover) color-indicator {
      box-shadow: 0 0 1px 1px #0005 inset, 0 0 0px 1px #0008 inset,
                  0 0 0 var(--sw2) #000,
                  calc(var(--w) * -.5px) 0px 0px var(--sw1) #3039,
                  0 0 0 #0000,
                  0 0 0 #0000,
                  calc(var(--w) * 0.5px) 0px 0px var(--sw3) #3039;
    }
    &:has(>color-result color-swatch[monochrome]:hover) color-indicator {
      bottom: calc(100% - 14px - var(--h) * 0.1px);
      outline: 1px solid #0000;
      box-shadow: 0 0 1px 1px #0005 inset, 0 0 0px 1px #0008 inset,
                  0 0 0 var(--sw1) #000,
                  0 calc(var(--h) * 0.1px) 0 var(--sw2) #F6F9,
                  0 calc(var(--h) * 0.2px) 0 var(--sw3) #E5E9,
                  0 calc(var(--h) * 0.3px) 0 var(--sw4) #C4C9,
                  0 calc(var(--h) * 0.4px) 0 var(--sw5) #A3A9,
                  0 calc(var(--h) * 0.5px) 0 var(--sw6) #8289,
                  0 calc(var(--h) * 0.6px) 0 var(--sw7) #4149,
                  0 calc(var(--h) * 0.7px) 0 var(--sw8) #2029,
                  0 calc(var(--h) * 0.8px) 0 var(--sw9) #0009;
    }
    &:has(>color-result color-swatch[success]:hover) color-indicator {
      bottom: calc(100% - 14px - var(--h) * 0.48px);
      right: calc(100% - 14px - var(--w) * 0.0166px);
      outline: 1px solid #0000;
      box-shadow: 0 0 1px 1px #0005 inset, 0 0 0px 1px #0008 inset,
                  0 0 0 var(--sw1) #000,
                  calc(var(--w) * 0.117px) 0 0 var(--sw2) #5529,
                  calc(var(--w) * 0.372px) 0 0 var(--sw3) #2A29,
                  calc(var(--w) * 0.544px) 0 0 var(--sw4) #2289,
                  calc(var(--w) * 0.711px) 0 0 var(--sw5) #0000;
    }
  }
  color-point {
    width: 16px;
    height: 16px;
    background: #0003;
    resize: both;
    overflow: auto;
    max-width: 100cqw;
    max-height: calc(var(--h) * 1px);
    opacity: 0;
    clip-path: polygon(calc(100% - 16px) calc(100% - 16px), calc(100% - 16px) 100%, 100% 100%, 100% calc(100% - 16px));
    /* for mobile support */
    touch-action: none;
  }
  color-indicator {
    display: block;
    position: absolute;
    width: 8px;
    height: 8px;
    border-radius: 16px;
    pointer-events: none;
    border: 2px solid #FFF;
    outline: 1px solid #000;
    box-shadow: 0 0 1px 1px #0005 inset, 0 0 0px 1px #0008 inset;
    transition: box-shadow 0.5s, bottom 0.5s, right 0.5s, outline 0.5s;
    right: 2px;
    bottom: 2px;
    /* make it easier to grab the thing on mobile */
    @media (pointer: coarse) {
      right: -2px;
      bottom: -2px;
    }
  }
  color-result {
    container-type: size;
    width: 100%;
    pointer-events: none;
    &>div {
      --cqw: calc(50cqw);
      --cqh: calc(50cqh);
      --picked-color: hsl(calc(tan(atan2(var(--cqw), 1px)) / calc(var(--w) / 720)) 100% calc(tan(atan2(var(--cqh), 1px)) / calc(var(--h) / 200)));
      --picked-color-hue: calc(tan(atan2(var(--cqw), 1px)) / calc(var(--w) / 720));
      display: flex;
      position: absolute;
      width: calc(var(--w) * 1px);
      height: 400px;
      top: calc(var(--h) * 1px);
    }
  }
  color-style {
    color-scheme: initial;
    /*--background: lch(from var(--picked-color) calc((1 - round(l / 128)) * 255) c h);*/
    --primary: var(--picked-color);
    --primary-contrast: lch(from var(--primary) calc((1 - round(l / 128)) * 255) 0 0);
    --secondary: lch(from var(--primary) l calc(min(255 - c * 10,148 - 100*round(l/100))) var(--picked-color-hue));
    --secondary-contrast: lch(from var(--secondary) calc((1 - round(l / 128)) * 255) 0 0);
    --complimentary: lch(from var(--primary) l c calc(h + 180));
    --complimentary-contrast: lch(from var(--complimentary) calc((1 - round(l / 128)) * 255) 0 0);
    --analogous-a: hsl(from var(--primary) calc(h - 40) s l);
    --analogous-b: hsl(from var(--primary) calc(h - 20) s l);
    --analogous-c: hsl(from var(--primary) calc(h + 20) s l);
    --analogous-d: hsl(from var(--primary) calc(h + 40) s l);
    --success: hsl(from var(--primary) 140deg 70 50%);
    --danger: hsl(from var(--primary) 6deg 76 50%);
    --warning: hsl(from var(--primary) 48deg 83 50%);
    --info: hsl(from var(--primary) 202deg 70 50%);
    --monochrome-100: lch(from var(--primary) 10% c h);
    --monochrome-200: lch(from var(--primary) 20% c h);
    --monochrome-300: lch(from var(--primary) 30% c h);
    --monochrome-400: lch(from var(--primary) 40% c h);
    --monochrome-500: lch(from var(--primary) 50% c h);
    --monochrome-600: lch(from var(--primary) 60% c h);
    --monochrome-700: lch(from var(--primary) 70% c h);
    --monochrome-800: lch(from var(--primary) 80% c h);
    --monochrome-900: lch(from var(--primary) 90% c h);

    --background: lch(from var(--primary) 100% calc(c / 5) h);

    /* https://github.com/system-fonts/modern-font-stacks?tab=readme-ov-file#geometric-humanist */
    font-family: Avenir, Montserrat, Corbel, 'URW Gothic', source-sans-pro, sans-serif;
    pointer-events: all;
    border: 1px solid color-mix(in hsl, var(--primary), #4444);
    background: var(--background);
    border-radius: 4px;
    margin: 8px;
    display: block;
    height: fit-content;
    width: 100%;
    @media (width < 480px) {
      font-size: 75%;
    }
    @media (width < 360px) {
      font-size: 50%;
    }
    &:not(:has(>color-swatch>div:active)) > color-swatch > div:hover {
      padding-left: 64px;
    }
    color-swatch {
      color-scheme: only light;
      margin: 10px;
      border-radius: 4px;
      overflow: clip;
      color: #000;
      width: calc(100% - 20px);
      height: 64px;
      display: flex;
      box-shadow: 1px 1px 5px 0 #0004;
      >div {
        flex-grow: 1;
        padding: 4px;
        font-weight: 600;
        display: flex;
        justify-content: flex-end;
        align-items: flex-end;
        -webkit-user-select: none;
        user-select: none;
        transition: padding 0.4s, box-shadow 0.4s, scale 0.4s;
        cursor: grab;
        box-shadow: none;
        &:active {
          cursor: grabbing;
          /*padding-left: 32px;*/
          padding-left: 52px;
          box-shadow: 0 0 16px #0005;
          z-index: 10;
        }
        &:last-child {
          margin-right: -1px;
        }
      }
      &[success] {
        color: #FFF;
        >:nth-child(1) { background: var(--success); color: var(--success-contrast); }
        >:nth-child(2) { background: var(--danger); color: var(--danger-contrast); }
        >:nth-child(3) { background: var(--warning); color: var(--warning-contrast); }
        >:nth-child(4) { background: var(--info); color: var(--info-contrast); }
      }
      &[primary] {
        >:nth-child(1) { background: var(--primary); color: var(--primary-contrast); }
        >:nth-child(2) { background: var(--complimentary); color: var(--complimentary-contrast); }
        >:nth-child(3) { background: var(--secondary); color: var(--secondary-contrast); }
      }
      &[monochrome] {
        >:nth-child(-n+5) { color: #FFF; }
        >:nth-child(1) { background: var(--monochrome-100); }
        >:nth-child(2) { background: var(--monochrome-200); }
        >:nth-child(3) { background: var(--monochrome-300); }
        >:nth-child(4) { background: var(--monochrome-400); }
        >:nth-child(5) { background: var(--monochrome-500); }
        >:nth-child(6) { background: var(--monochrome-600); }
        >:nth-child(7) { background: var(--monochrome-700); }
        >:nth-child(8) { background: var(--monochrome-800); }
        >:nth-child(9) { background: var(--monochrome-900); }
      }
      &[analogous] {
        color: var(--primary-contrast);
        >:nth-child(1) { background: var(--analogous-a); }
        >:nth-child(2) { background: var(--analogous-b); }
        >:nth-child(3) { background: var(--primary); }
        >:nth-child(4) { background: var(--analogous-c); }
        >:nth-child(5) { background: var(--analogous-d); }
      }
    }
  }
}

Safari сейчас некорректно обрабатывает единицы измерения cqw/cqh, поэтому демо выше может работать неправильно. Если это произошло, попробуйте открыть его в Firefox или Chrome.

Сегодня в CSS появилось так много классных возможностей, которые делают его написание чуточку приятнее. Например, можно использовать (width <= 768px) вместо (max-width: 768px) в @media-запросах, единицу lh, которая равна высоте строки, свойство scrollbar-gutter, решающее проблему скачков верстки из-за появления полосы прокрутки, или наконец-то центрировать элементы по вертикали без flex/grid.

И всё это объединяет вишенка на торте — Baseline. Это гарантия того, что конкретная функция работает во всех основных браузерах, а также информация о том, с какого момента — новейшие возможности работают во всех актуальных браузерах, а широко доступные — в версиях возрастом до 2,5 лет. Например, вложенность полностью поддерживается во всех браузерах с декабря 2023 года и станет «широко доступной» в июне 2026-го. Символы Baseline можно найти в разных местах, например в документации MDN.

И это лишь несколько примеров того, почему писать современный CSS намного приятнее, чем пять лет назад. Почти как сравнивать ES3 с ECMAScript 2025 — и я бы не стал вас винить, если вы застряли на первом и до сих пор храните на него обиду.

Зачем вообще заморачиваться?

Ну хорошо, в CSS теперь больше удобных возможностей. Но зачем выбирать его вместо чего-то другого? Разве в JavaScript уже нельзя сделать всё, что нужно?

Мои причины использовать CSS можно свести к двум основным — потому что некоторые пользователи не хотят включать JavaScript и потому что во многих случаях сделать это в CSS действительно лучше.

Некоторые пользователи не хотят включать JavaScript
Некоторые пользователи не хотят включать JavaScript

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

То же самое касается людей, заботящихся о приватности — и это логично! В качестве эксперимента я открыл эстонский новостной сайт с включённым JavaScript. Сколько, как думаете, js-файлов он подтянул? (93!) Сумасшествие! Вы точно хотите, чтобы всё это крутилось на вашем компьютере?

Но вы же наверняка не из тех злодеев-разработчиков, которые грузят десяток аналитических скриптов на свой сайт — есть ли тогда смысл делать что-то через CSS?

Да, и немалый. Многие вещи просто удобнее делать в HTML/CSS — и с точки зрения разработчика, и с точки зрения пользователя: будь то простота реализации, доступность или производительность.

Ховер-эффекты для кнопок? Анимации для toast-уведомлений? Валидация форм? Всё это прекрасно работает в CSS — и вам не придётся изобретать велосипед или тянуть килобайты чужого кода. Конечно, есть ситуации, где нужна гибкость, которую даёт JavaScript, но если она не требуется, а CSS решает задачу проще — почему бы не сэкономить себе силы?

И производительность у CSS куда лучше! Каждое взаимодействие на JavaScript проходит через event loop, который тратит циклы процессора, подъедает батарею и добавляет лёгкие подёргивания во всё, что происходит на экране.

Конечно, в общем и целом это не так уж страшно — API вроде requestAnimationFrame отлично справляются с тем, чтобы сделать анимации плавными. Но CSS-анимации работают в отдельном потоке композитора и не зависят от лагов или блокировок в event loop.

Разница особенно заметна на слабых устройствах, но и на мощных ощущается приятно. CSS-анимации на моём мониторе с частотой 240 Гц выглядят потрясающе. JS тоже может выглядеть неплохо, но есть едва заметное подёргивание, которое мешает достичь идеальной плавности — особенно если параллельно выполняется тяжёлый код.

А ещё вам не придётся так много думать об оптимизациях — браузер сам берёт на себя большую часть работы по рендерингу и зачастую запускает всё на GPU, если это возможно.

Совет: всё равно хотите запускать анимации из JS? Используйте современный Web Animations API — он позволяет легко проигрывать плавные CSS-анимации прямо из кода.

Переходы

Кстати о переходах — думаю, пора показать несколько практических примеров. Отличная отправная точка — это @starting-style.

Раньше добавлять анимацию появления (например, плавного проявления) было довольно мучительно. Нужно было либо создавать полноценную CSS-анимацию с отдельным блоком @keyframes, либо делать всё через JS — сначала добавить элемент на страницу, затем дождаться одного кадра, а потом добавить ему класс.

.toast {
  transition: opacity 1s, translate 1s;
  opacity: 1;
  translate: 0 0;
  @starting-style {
    opacity: 0;
    translate: 0 10px;
  }
}

Но теперь всё изменилось благодаря новому правилу @starting-style!

Всё, что нужно — прописать свойства как обычно, задать начальные состояния в @starting-style и добавить эти свойства в transition. Всё довольно просто, и анимация срабатывает сама по себе, без ручного «триггера».

Lunalover

Ещё один отличный пример того, где CSS раскрывается во всей красе — это темы. Многие сайты сейчас делают отдельные светлую и тёмную темы, и современный CSS заметно упрощает эту задачу.

:root {
  color-scheme: light dark;
  --text: light-dark(#000, #FFF);
  --bg: light-dark(#EEE, #242936);
}

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

Это влияет не только на ваши собственные цвета, но и на нативные элементы интерфейса — кнопки, поля форм, полосы прокрутки. Всё начинает «просто работать» само по себе — и это приятно!

:root {
  color-scheme: light dark;
  &:has(#theme-light:checked) {
    color-scheme: light;
  }
  &:has(#theme-dark:checked) {
    color-scheme: dark;
  }
}

Дальше можно добавить возможность переопределять color-scheme, чтобы пользователь мог выбрать тему независимо от системных настроек. В моём примере это реализовано с помощью радиокнопок.

Совет: CSS не может сохранять выбранную тему, но можно использовать прогрессивное улучшение. Сначала сделайте так, чтобы темы работали только на CSS, а сохранение и загрузку предпочтений добавьте уже на JavaScript или сервере — как дополнительную опцию.

Лиры и аккордеоны

«Но это же совсем не похоже на радиокнопки!» — слышу я ваши возгласы.

Элементы ввода вроде радиокнопок и чекбоксов — отличная основа, на которой можно построить что-то своё. Пример ниже состоит из подписей (labels) для кнопок и невидимых радиокнопок, состояние которых проверяется с помощью псевдокласса :checked.

<radio-picker aria-label="Radio buttons example" role="radiogroup">
  <label><input type="radio" name="demo" id="veni" checked>veni</label>
  <label><input type="radio" name="demo" id="vidi">vidi</label>
  <label><input type="radio" name="demo" id="vici">vici</label>
</radio-picker>
<style>
  radio-picker {
    display: flex;
    label {
      &:has(input:checked) {
        box-shadow: inset 0px 0px 8px 0px #888;
      }
      &:has(input:focus-visible) {
        outline: 2px solid #000;
      }
      box-shadow: inset 0px 0px 1.2px 0px #000;
      padding: 10px;
      cursor: pointer;
      background: #0002;
      &:hover { background: #0004; }
      &:active { background: #0006; }
    }
    input {
      /* To allow screen reader to still access these. */
      opacity: 0;
      position: absolute;
      pointer-events: none;
    }
  }
</style>

Так я сделал переключатель темы из предыдущего примера. В демо радиокнопки наполовину видимые ради наглядности, но с opacity: 0 они вообще не отображались бы.

Здесь происходит довольно много всего, так что разберём по частям.

<radio-picker aria-label="Radio buttons example" role="radiogroup">

Начнём с элемента radio-picker — я его просто придумал, можно использовать div, если так удобнее. Мы задаём ему aria-label, чтобы у группы было доступное имя, и роль radiogroup, чтобы всё работало как единая группа радиокнопок.

Можно также использовать элемент fieldset вместо aria-атрибутов, если это больше подходит вашему случаю.

<label><input type="radio" name="demo" id="veni" checked>veni</label>
<label><input type="radio" name="demo" id="vidi">vidi</label>
<label><input type="radio" name="demo" id="vici">vici</label>

Дальше добавляем радиокнопки с соответствующими подписями. Обычно для связки используется атрибут for в label, но так как у нас input находится внутри label, это не требуется.

У всех input type="radio" должно быть одно и то же значение name, чтобы они были объединены в группу (да, radiogroup всё равно нужен). Дальше вы можете задавать им любые value или id.

label {
  &:has(input:checked) {
    box-shadow: inset 0px 0px 8px 0px #888;
  }
  &:has(input:focus-visible) {
    outline: 2px solid #000;
  }
  box-shadow: inset 0px 0px 1.2px 0px #000;
  padding: 10px;
  cursor: pointer;
  background: #0002;
  &:hover { background: #0004; }
  &:active { background: #0006; }
}

Теперь стилизуем label как нам нужно. Псевдоклассы :hover и :active добавляют интерактивности, селектор :has(input:checked) задаёт стиль выбранной кнопки, а :has(input:focus-visible) добавляет обводку, когда пользователь переключается клавишей Tab.

Разница между :focus и :focus-visible в том, что первый срабатывает всегда, даже при клике мышкой, а второй только при навигации с клавиатуры. Поэтому визуально интерфейс с :focus-visible выглядит чище.

input {
  opacity: 0;
  position: absolute;
  pointer-events: none;
}

И напоследок мы делаем так, чтобы input продолжал существовать, но был невидим. Да, это немного «хак», но именно так можно сохранить доступность элемента для клавиатуры и экранных читалок.

И вот так мы получаем классные кастомные радиокнопки!

<radio-tabs>
  <div tabindex=0 id="tab-veni">veni...</div>
  <div tabindex=0 id="tab-vidi">vidi...</div>
  <div tabindex=0 id="tab-vici">vici...</div>
</radio-tabs>
<style>
  body:has(#veni:not(:checked)) #tab-veni,
  body:has(#vidi:not(:checked)) #tab-vidi,
  body:has(#vici:not(:checked)) #tab-vici {
    display: none;
  }
</style>

Теперь мы можем использовать их в CSS как угодно, просто проверяя, какая из них находится в состоянии :checked. В моём примере я сделал вкладки с отдельными div для контента, используя селектор :has на родительском элементе, чтобы определить, какая радиокнопка выбрана.

Важно: селектор :has нужно вешать на родительский элемент, который содержит и радиокнопку, и целевой элемент. Если хотите, чтобы это работало на всей странице, можно использовать html или body. Но никогда не используйте просто :has(…) без уточнения — в этом случае селектор выполнится для каждого элемента на странице, что может привести к проблемам с производительностью (а вот body:has(…) — нормально).

<div>
  <details name="deets">
    <summary>What's your name?</summary>
    My name is Lyra Rebane.
  </details>
  <details name="deets">
    ...
  </details>
</div>
<style>
  div {
    border: 1px solid #AAA;
    border-radius: 8px;
    /* based on the MDN example */
    summary {
      font-weight: bold;
      margin: -0.5em -0.5em 0;
      padding: 0.5em;
      cursor: pointer;
    }
    details {
      &:last-child { border: none }
      border-bottom: 1px solid #aaa;
      padding: 0.5em 0.5em 0;
      &[open] {
        padding: 0.5em;
        summary {
          border-bottom: 1px solid #aaa;
          margin-bottom: 0.5em;
        }
      }
    }
  }
</style>

И напоследок, перед тем как мы продолжим, хочу коротко рассказать о теге details. Он отлично подходит для создания аккордеон-меню, например, для раздела FAQ. По умолчанию блоки details открываются и закрываются независимо, но если задать всем один и тот же атрибут name, то открытым будет только один из них.

Использовать их просто: оберните контент в тег details, а заголовок поместите в summary. Пример выше немного сложнее ради красивой визуализации, но по сути вам нужен только HTML.

Элементы details хорошо стилизуются! Можно добавлять анимации в зависимости от состояния [open], а стрелочку легко убрать, задав list-style: none для summary.

А ещё с ними работает ctrl+f, и для меня это прямо огромный плюс!

Валидация

И напоследок хочу показать вам, насколько мощной может быть валидация полей ввода с помощью HTML и CSS.

<label for="usrname">Username</label>
<input type="text" id="usrname" pattern="\w{3,16}" required>
<small>3-16 letters, only alphanum and _.</small>
<style>
 input:valid {
   border: 1px solid green;
 }
 input:invalid {
   border: 1px solid red;
 }
</style>

Вот простой пример проверки поля по регулярному выражению. Если задать атрибут pattern, форма не сможет быть отправлена, пока значение поля не соответствует этому паттерну. Если вы проверяете, например, e-mail, номер телефона или URL, имеет смысл использовать соответствующие типы input, а не писать собственное регулярное выражение.

Где вступает в дело CSS — так это в стилизации поля в зависимости от того, корректно ли введено значение. В примере выше я использую псевдоклассы :valid и :invalid, чтобы менять цвет рамки, но у этого есть минус — поле будет подсвечено всегда, даже если пользователь ещё ничего не ввёл.

input {
  border: none;
  border-radius: 2px;
  outline: 1px solid #000;
  &:focus { outline-width: 2px; }
  &:user-valid { outline-color: green; }
  &:user-invalid { outline-color: red; }
}

Простой лайфхак — использовать :user-valid и :user-invalid. Эти псевдоклассы активируются только после того, как пользователь взаимодействовал с полем. В примере я использовал outline вместо border, и, на мой взгляд, это выглядит гораздо аккуратнее.

Иногда имеет смысл комбинировать :valid и :user-invalid.

И конечно, вы можете использовать селектор :has, чтобы менять стиль других элементов в зависимости от состояния input!

А этот пример — просто ради удовольствия ^_-!

Стоит отметить, что для некоторых вещей, например выбора даты (date picker) или выпадающих списков (datalist), уже существуют встроенные элементы, но они могут быть ограниченными. Если вам нужно что-то с особыми требованиями, возможно, придётся всё же слегка подключить JavaScript.

Не злоупотребляйте vw/vh

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

CSS имеет единицы vw и vh, которые соответствуют 1% ширины и высоты окна просмотра соответственно — и на десктопных браузерах это работает отлично.

Но на мобильных всё немного сложнее. Например, мобильные версии Firefox и Chrome скрывают адресную строку при прокрутке страницы вниз.

Из-за этого единицы vw/vh становятся двусмысленными: они означают всю доступную высоту экрана? Только видимую область вместе с адресной строкой? Или что-то среднее?

Если это первое, кнопки или ссылки могут оказаться за пределами экрана¹! Если второе — ваш div с фоном может не закрывать всю страницу.

Решение — использовать новые адаптивные единицы измерения: lvhsvh и dvh.

  • lvh — largest viewport height (наибольшая высота окна просмотра). Подходит для элементов вроде фона, который должен покрывать весь экран, даже если часть может быть обрезана.

  • svh — smallest viewport height (наименьшая высота окна просмотра). Отлично подходит для кнопок или ссылок, которые всегда должны быть видимы.

  • dvh — dynamic viewport height (динамическая высота окна просмотра). Эта единица обновляется в соответствии с текущей высотой экрана. Кажется очевидным выбором, но её лучше не использовать для элементов, которые не должны менять размер при прокрутке страницы, иначе это может раздражать пользователя и даже вызывать лаги.

Разумеется, есть и соответствующие единицы ширины: lvwsvw и dvw.

Клавиатурный кот

По умолчанию единицы vw/vh не учитывают появление экранной клавиатуры поверх страницы.

Есть два способа справиться с этим: атрибут interactive-widget и VirtualKeyboard API.

Первый вариант широко поддерживается браузерами, не требует JavaScript и указывается в метатеге viewport. При его использовании открытие клавиатуры пересчитывает все единицы окна просмотра:

<meta name="viewport" content="width=device-width, interactive-widget=resizes-content">

Второй вариант пока работает только в браузерах на Chromium и требует одной строки JavaScript:

navigator.virtualKeyboard.overlaysContent = true;

Преимущество второго способа в том, что он позволяет использовать в CSS переменные окружения для получения позиции и размера клавиатуры — что выглядит очень круто:

floating-button {
  margin-bottom: env(keyboard-inset-height, 0px);
}

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

CSS wishlist

Ну а теперь — немного в сторону от основной темы, но мне хочется рассказать о вещах, которых, как мне кажется, не хватает в CSS. Я не продумал их до конца, так что некоторые в текущем виде точно не впишутся в спецификацию, но, возможно, они вдохновят кого-то на новые идеи.

Это скорее фан, так что не воспринимайте слишком серьёзно.

Переиспользуемые блоки

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

.border {
  border: 2px solid;
  border-radius: 4px;
}

.button {
  @apply border;
}

.card {
  @apply border;
}

Такое уже есть в Tailwind — и да, я немного завидую.

Комбинированные селекторы @media

Сейчас мы можем вкладывать @media друг в друга, а также использовать несколько селекторов одновременно:

div {
  &.foo, &.bar {
    color: red;
    padding: 8px;
    font-size: 2em;
  }
  @media (width < 480px) {
    color: red;
    padding: 8px;
    font-size: 2em;
  }
}

Но мы не можем объединить эти два подхода в один селектор:

div {
  @media (width < 480px), &.foo {
    color: red;
    padding: 8px;
    font-size: 2em;
  }
}

А значит, если вам нужно такое поведение, неизбежно придётся дублировать код или прибегать к странным трюкам с переменными — ни то, ни другое не идеально.

Переменная n-го дочернего элемента

Я часто пишу такой код:

div {
  span:nth-child(1) { --nth: 1; }
  span:nth-child(2) { --nth: 2; }
  span:nth-child(3) { --nth: 3; }
  span:nth-child(4) { --nth: 4; }
  span:nth-child(5) { --nth: 5; }
  ...
  span {
    top: calc(--nth * 24px);
    color: hsl(calc(var(--nth) * 90deg) 100 90);
  }
}

И, по-моему, было бы куда приятнее, если бы можно было написать так:

div {
  span {
    --nth: nth-child();
    top: calc(--nth * 24px);
    color: hsl(calc(var(--nth) * 90deg) 100 90);
  }
}

Выбор n-й буквы

В CSS можно стилизовать ::first-letter текста. Было бы круто, если бы существовал селектор ::nth-letter(…), похожий на :nth-child. Подозреваю, что причина его отсутствия в том, что ::first-letter — это псевдоэлемент, и реализовать идею с nth-letter было бы немного сложнее.

/* это не реальная фича */
p::nth-letter(2) {
  color: red;
}

Blackle предложил, что комбинировать переменную nth-child() с выборкой :nth-letter было бы весело для некоторых эффектов — например, можно было бы подставлять значение в функцию sin(), чтобы создавать волнистый текст:

div {
  /* это не реальная фича */
  --nth: nth-child(nth-letter);
  will-change: transform;
  translate: 0 calc(sin(var(--nth) * 0.35 - var(--wave) * 3) * 5px);
  color: color-mix(in oklch, #58C8F2, #EDA4B2 calc(sin(var(--nth) * 0.5 - var(--wave)) * 50% + 50%));
}

Удаление единиц измерения

Хотелось бы иметь простой способ убрать единицы измерения у значений — например, делением:

div {
  /* Превращается в число без единицы */
  --screen-width: calc(100vw / 1px);
  color: hsl(var(--screen-width) 100, 50);
}

Так можно было бы использовать размер окна или контейнера как числовую переменную не только для длин. Например, color picker из примера выше использует это, чтобы перевести позицию точки выбора цвета в число и подставить его в значение цвета.

«Погодите, так что, эта фича уже есть?»

Да, лол! Мы уже можем получать значения без единиц в CSS, но это требует хаков вроде tan(atan2(var(--vw), 1px)) с использованием @property. Было бы здорово иметь возможность делать это просто делением.

И хорошие новости — похоже, это мы действительно скоро получим!

А ещё, если вы сделаете что-то вроде calc(1px + sqrt(1px * 1px)), ваш браузер упадёт.

Лучшая функция для изображений

Функция image() существует, но её пока не реализовал ни один браузер. Она похожа на url(), но добавляет крутые возможности: цвет-заглушку на случай, если картинка не загрузилась, и «фрагменты» изображения — чтобы вырезать нужный кусочек из большого (как в spritesheets).

Да, мы уже можем делать и fallback, и спрайты через разные свойства background, но эта лаконичная запись была бы куда приятнее. Честно говоря, я бы хотел такую же возможность даже для тегов <img>, не только для CSS.

Теги <style> в <body>

В своих проектах я активно использую теги <style> внутри <body>. Например, в блоге я пишу соответствующий CSS прямо рядом с графикой, чтобы можно было начать читать статью до того, как загрузится вся страница (или весь CSS). И это отлично работает!

Плохо лишь то, что несмотря на поддержку браузерами и использование на крупных сайтах, это официально не соответствует спецификации. Думаю, причина запрета в спеке — защита от FOUC (эффекта «мигающих» стилей), но есть так много сценариев, где style в body реально нужен, что запрет кажется излишним.

Мне кажется, HTML-валидатор должен выдавать предупреждение, но не ошибку.

Искусство

Хочу закончить эту статью тем, что для меня веб-разработка — это искусство, а значит, и CSS тоже. Мне часто трудно понять людей, которые занимаются вебом исключительно ради денег или стартапа: когда ты в команде и получаешь задачи сверху, а не сам решаешь, что и ради удовольствия создавать, веб-разработка ощущается совсем иначе.

Это особенно заметно на примере ИИ, который для меня как будто выхолащивает из работы весь кайф и творческую искру. Но это относится и к инструментам сборки — линтерам и минификаторам: то, как я пишу код, — часть моего творчества, и я не хочу, чтобы тул это стирал. Я даже IDE не использую.

Помимо практических причин придерживаться CSS, о которых я писал выше, есть ещё одна — скрытая — из-за которой мне нравится делать всё в CSS: самовыражение и искусство. Искусство не всегда про практичность, и CSS — тоже. Но так я выражаю себя, и ради этого я и занимаюсь тем, чем занимаюсь.

Я старался сделать этот текст понятным и полезным для всех веб-разработчиков. Но у меня ещё много чего есть сказать про CSS, так что ждите продолжение — о вещах, которые не столько практичны, сколько чертовски классны. Я считаю CSS языком программирования и даже сделал игру, чтобы это доказать.

Но об этом — в другой раз.

Русскоязычное JavaScript сообщество

Друзья! Эту статью перевела команда «JavaScript for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Frontend. Подписывайтесь, чтобы быть в курсе и ничего не упустить!

Послесловие

Прошёл почти год с момента моего прошлого поста, но надеюсь, ожидание того стоило ^_^

Как обычно, этот пост — самодостаточный HTML-файл без javascript, изображений и внешних ресурсов: вся страница написана вручную на html/css и весит около 49 кБ в gzip. было очень весело придумывать маленькие интерактивные виджеты и визуальные штуки — думаю, с прошлого поста я неплохо прокачал css.

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

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


  1. CitizenOfDreams
    15.09.2025 09:47

     сайты могут быть быстрыми, красивыми и интерактивными даже без JS

    "Даже"?