Команда 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.

Исходный код
/* 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. Сколько, как думаете, 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 с фоном может не закрывать всю страницу.

Решение — использовать новые адаптивные единицы измерения: lvh, svh и dvh.
lvh — largest viewport height (наибольшая высота окна просмотра). Подходит для элементов вроде фона, который должен покрывать весь экран, даже если часть может быть обрезана.
svh — smallest viewport height (наименьшая высота окна просмотра). Отлично подходит для кнопок или ссылок, которые всегда должны быть видимы.
dvh — dynamic viewport height (динамическая высота окна просмотра). Эта единица обновляется в соответствии с текущей высотой экрана. Кажется очевидным выбором, но её лучше не использовать для элементов, которые не должны менять размер при прокрутке страницы, иначе это может раздражать пользователя и даже вызывать лаги.
Разумеется, есть и соответствующие единицы ширины: lvw, svw и 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)

Zippy
15.09.2025 09:47представляю себе недоумение последнего поколение разрабов, убежденных что веб и ReactJs это одно и то же

nin-jin
15.09.2025 09:47Ничего, скоро в CSS и JSX завезут.

Ascard
15.09.2025 09:47Это какие-то слишком старые разрабы, отнюдь не "последнее поколение". Совсем молодые дальше строки промта условного chatGPT могут и не уйти. Сейчас немало конструкторов где между промтом и деплоем нет промежутка. Написал промт, получил результат, нажал деплой - получил ссылку на развёрнутый апп, готово!

strwolf
15.09.2025 09:47А ещё раньше был Front page 2002 года с попыткой в технологию WYSIWYG, что вижу то и отображается. Потом ушли в сторону написания в ручную стилей, далее уход в JavaScript потом в typescript, затем в React и сейчас снова пытаются уйти в CSS, в стили похожи на псевдо язык с переходами, циклами и переменными. Все что есть в обычном языке программирования только над стилями. Сейчас в переменных содержатся куски стилей ага очень просто) сарказм. Хотя фронтендерам может и нормально, я не фронтендер

Nurked
15.09.2025 09:47Хватит бухтеть. В моём детстве это было $() и люди не могли отличить JS от JQuery.

ferticultyap
15.09.2025 09:47JQuery навсегда останется в наших сердцах. Он был очень революционный в свое время. Работал на одном легаси где переводили с jQuery на vue. Словил нотки ностальгии... столько кода было написано мною на нем в свое время... эххх

venanen
15.09.2025 09:47Представляю их ещё большее недоумение, если сайты откажутся от реакта и вернутся в старый стиль. Когда у формы action="/" и при ошибке все поля сбрасываются, и нормально перезагрузить страницу нельзя, потому что всплывает окно об отправке формы. PhpBB - я тебя помню, сволочь, и все так же сильно ненавижу

Ascard
15.09.2025 09:47Было бы неплохо ещё увидеть примеры хоть сколько-нибудь полезных сайтов вообще без JS. Ну то есть что-то посложнее лендинга с набором статичных фреймов и формы "Напишите нам" с валидацией адреса электропочты, на который мы вам "обязательно ответим".

nin-jin
15.09.2025 09:47Тем более, что дефолтная браузерная валидация не корректная:

Корректная регулярка выглядела бы куда как сложнее:
/^(?:((?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}(?:\.(?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}){0,})|("(?:((?:(?:([\u{1}-\u{8}\u{b}\u{c}\u{e}-\u{1f}\u{21}\u{23}-\u{5b}\u{5d}-\u{7f}])|(\\[\u{1}-\u{9}\u{b}\u{c}\u{e}-\u{7f}]))){0,}))"))@(?:((?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}(?:\.(?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}){0,}))$/gsu

andreymal
15.09.2025 09:47Примерно любой сайт до ~2010-го — в те древние времена ещё считалось хорошим тоном обеспечивать работоспособность сайта без JS
Можно глянуть какие-нибудь выжившие старые форумы на «классических» движках вроде PhpBB или MyBB — без JS отвалится какая-нибудь мелочь вроде кнопочек форматирования в редакторе, но вся основная функциональность останется рабочей
Жаль, что нынешнее поколение веб-разработчиков разучилось так делать, вон комментатор ниже даже не представляет как можно жить без API

Ascard
15.09.2025 09:47phpBB - плохой пример. Берём случайный коммит за 2010 год и смотрим тамошний core.js
Куча кода: ajax, jquery, попапы, дропдауны, рекапча, аплоад файлов, и т.д. Я не думаю что без всего этого оно будет работать нормально.
Но вашу идею я понял.
andreymal
15.09.2025 09:47Можно отметить, что этот файл появился в 2012-м, а раньше как-то жили и без него)

Ascard
15.09.2025 09:47Да я уже заметил что промахнулся с временем, но комментарий было не изменить. Прошу всяческих извинений.

Harconnen
15.09.2025 09:47Да было время )). В конце 90 мы писали на perl, делали чаты, форумы, сделали свою смс систему для онлайн магазинов, сделали прицеп для 1С. Практически все страницы были сгенерированы, там где динамика использовали SSI + httaccess. В итоге все это свистело даже на самых слабых хостингах.

sidorovkv
15.09.2025 09:47А с чего жалость то? UX возможности приложений написанных на JS гораздо выше чем у многостраничных сайтов. Кроме того такой подход позволил разделить компетенции разработчиков на frontend и backend части и разработку их отдельно друг от друга. Работа с бэком через api это наиболее вменяемый путь развития, потому как фронты могут быть web, mobile, smart tv и прочее, а api при этом один и тот же.

andreymal
15.09.2025 09:47Опять эта пачка заезженных мифов)
чем у многостраничных сайтов
На многостраничных сайтах тоже никто не запрещает использовать JS и сделать богатый UX, это не взаимоисключающие вещи
такой подход позволил разделить компетенции разработчиков на frontend и backend части и разработку их отдельно друг от друга
И ничего хорошего в этом нет, по множеству разных причин это лишь приводит к ухудшению качества сайтов
фронты могут быть web, mobile, smart tv и прочее
Иронично, что в реальности всё происходит наоборот — все всё запихивают в веб, и многие «нативные» приложения являются всего лишь обёртками над webview
Но независимо от этого — ничто не мешает использовать один и тот же api для многостраничного сайта и для нативного приложения, лишь бы было желание нормально организовать структуру бэкенда

nin-jin
15.09.2025 09:47И ничего хорошего в этом нет, по множеству разных причин это лишь приводит к ухудшению качества сайтов
Например?

andreymal
15.09.2025 09:47Много букв
Если мы рассматриваем ситуацию, когда бэк только отдаёт API, а фронт это SPA, самое очевидное — сайт перестаёт работать без JS, пользователь вынужден разрешать на своём устройстве remote code execution и подвергать себя в худшем случае риску безопасности (дыры в песочницах и сторонние каналы никто не отменял), в лучшем случае майнингу бетховенов (утрирую, но суть вы поняли)
Так как бэк вряд ли станет делать эндпоинт, который отдаст всю нужную странице информацию за раз — фронт вслед за хотелками фронтендеров станет отправлять стопицот ajax-запросов на каждый чих: тот же Хабр при открытии комментов отправляет 7 ajax-запросов, хотя теоретически ничего не мешало бы уложиться в один запрос и сэкономить время (и трафик, потому что всё вместе наверняка сожмётся лучше). И нет, отмазки наподобие «можно сперва быстро загрузить и показать основной контент страницы, а уже потом подгружать менее важные части» здесь не прокатят: во-первых, загрузка и запуск JS сами по себе потратят сколько-то времени, а во-вторых, браузеры умеют показывать страницу ещё в процессе загрузки, так что пользователь может сразу начать читать основной контент вообще без использования JS и даже не дожидаясь окончания загрузки этого основного контента — это ВСЕГДА будет быстрее чем рендеринг контента через JS
Если бэк не озаботится чем-то вроде GraphQL — он будет отдавать в том числе ненужную информацию и впустую тратить трафик: тот же Хабр при открытии комментов зачем-то отдаёт полные тексты постов, которые при этом никогда не отображаются
Браузер начинает выполнять двойную работу: сперва он тратит энергию на парсинг json, а потом на преобразование этого json в DOM по замудрёным алгоритмам — это ж какой углеродный след получается, куда только экоактивисты смотрят? А парсинг html никуда не девается, потому что регидратация и потому что на том же Хабре тексты постов всё равно приходят в html (и при этом завёрнутые в json, ага). А устройства у пользователей далеко не новые, и всё это дело успешно тормозит. Всё это можно было бы не только быстро сделать на высокопроизводительном бэке (особенно если взять столь же высокопроизводительный язык, но Rust это же экономически невыгодно ага), но и закэшировать на том же бэке (хотя бы для отдельных частных случаев) и не тратить миллионы часов и ватт пользовательских устройств на бессмысленное повторение одних и тех же действий
А уж как бесит криво сделанная гидратация на некоторых сайтах: сперва за несколько секунд подгружается обычный html, я начинаю его читать, а потом он ВНЕЗАПНО пропадает и заменяется на экран загрузки, чтобы потратить ещё несколько секунд и процентов заряда батареи на преобразование json в тот же самый html! Хочется бить ногами за такое
Ну и ещё не совсем про техническую часть, а скорее про дисциплину — часто фронт оказывается избалован одностраничностью и начинает плевать на семантику. Так как ничего не заставляет использовать родные браузерные механизмы — разработчики фронта начинают велосипедить свои собственные механизмы, которые работают хуже чем родные браузерные и приводят к появлению проблем, которых изначально не появилось бы, если бы фронт не выпендривался. Зачем делать нормальную форму с нормальной кнопкой отправки, если можно просто повесить onclick на div? Пофиг, что форма перестала отправляться по энтеру, будет много жалоб — добавим onkeypress. Зачем делать ссылки через тег <a>, если можно просто повесить onclick на подчёркнутый span? Ну и что, что Ctrl+клик перестал открывать псевдоссылку в новой вкладке, этим пользуются всего 0.00001% пользователей, добавлять обработку event.ctrlKey экономически невыгодно, использовать нормальный <a> ещё более невыгодно

nin-jin
15.09.2025 09:47Вот вам контр пример: https://page.hyoo.ru/#!=u4oxgj_q8dpwd
Приложение мгновенно поднимается из офлайна.
Если уже был на станице - статья сразу показывается из кеша.
В фоне грузится новая версия, если есть изменения, и тут же показывается.
Рендерится не вся статья, а лишь то, что попадает в видимую область.
Парсится статья тоже лениво.
Если сервер по любой причине не отвечает - приложение не ломается.
А вот, что видят поисковики: https://page.hyoo.ru/?_escaped_fragment_=%3Du4oxgj_q8dpwd

andreymal
15.09.2025 09:47Приложение мгновенно поднимается из офлайна.
Во-первых, это плохо, потому что всё ещё требует JS. Во-вторых, в оффлайне картинки отвалились — ну и какой смысл в таком оффлайне? Лучше бы я увидел обычную ошибку подключения к интернету
Если уже был на станице - статья сразу показывается из кеша.
Возможно, это работает хорошо при автоподгрузке изменений, но на обычном сайте это будет плохо, потому что я рискую увидеть устаревшую версию
В фоне грузится новая версия, если есть изменения, и тут же показывается.
Если прям совсем сразу же показывается новая версия, то это плохо, потому что нарушается консистентность: первую половину я прочитаю старую, вторую половину прочитаю новую и запутаюсь. Лучше просто уведомление вида «Есть изменение, обновить страницу?» Но такое уведомление можно сделать и на классическом многостраничном сайте
Рендерится не вся статья, а лишь то, что попадает в видимую область.
И это ОЧЕНЬ плохо: я хочу заранее загрузить весь контент целиком и потом спокойно его читать без раздражающих подгрузок, а то вон в оффлайне у меня уже картинки отвалились
Парсится статья тоже лениво.
Аналогично предыдущему пункту. Кстати, на моём телефоне прокрутка подтупливает — подозреваю, что поэтому
Если сервер по любой причине не отвечает - приложение не ломается.
А лучше бы ломалось. Я всё в том же оффлайне тыкнул в рандомную статью и получил вечную загрузку — а нельзя мне было сказать «Включи интернет, придурок»?
В общем неудачный пример по (почти) всем параметрам
Версия для поисковиков гораздо приятнее в пользовании: и грузится быстрее, и прокрутка не лагает, и загружается сразу всё целиком, так что можно спокойно читать в оффлайне с картинками

nin-jin
15.09.2025 09:47в оффлайне картинки отвалились
Это, разумеется, не так. Все загруженные картинки остаются в кеше. Вы до них просто не долистали.
на обычном сайте это будет плохо, потому что я рискую увидеть устаревшую версию
Вот как раз на обычном сайте увидеть устаревшую версию предпочтительнее, чем не увидеть ничего. А на необычных сайтах отображается статус актуальности, обновляющийся в реальном времени.
первую половину я прочитаю старую, вторую половину прочитаю новую и запутаюсь
За пол секунды вы ничего не прочитаете. Вы не супергерой.
я хочу заранее загрузить весь контент целиком и потом спокойно его читать
А я не хочу тратить трафик на загрузку всех картинок, если прочитав пару параграфов, пойму, что написана чушь. Вы тут не один.
тыкнул в рандомную статью и получил вечную загрузку
И у вас всё ещё есть возможность тыкнуть на другую рандомную статью и получить её из кеша. Вы даже не поняли, о чём вам говорят.
лучше бы ломалось
Проспитесь перед следующим ответом.
CitizenOfDreams
"Даже"?