[Перевод] Эффект матового стекла для веба

В процессе разработки UI для игр Forza Horizon 3 и Forza Motorsport 7 я имел возможность поработать с потрясающими акриловыми матовыми элементами дизайна. Вот пример из Horizon 3:

a60b2991104b526e7984d1badf396449.webp

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

dd423500f59925477ea50e7a80b13ffe.png

Готовый код

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

Посмотреть код и ассеты

Ассет отражения света

c3e16d6e426b7d6bb20f81c1a272caf9.png

Версия с JavaScript

Работает на всех платформах.

HTML

Drag Me

CSS

.glass {
  /* Эффект размытия */
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);

  box-shadow:
    /* Нижний и правый эффект глубины */
    inset -0.75px -0.5px rgba(255, 255, 255, 0.1),
    /* Верхний и левый эффект глубины */
    inset +0.75px +0.5px rgba(255, 255, 255, 0.025),
    /* Shadow effect */
    3px 2px 10px rgba(0, 0, 0, 0.25),
    /* Короткий подповерхностный эффект */
    inset 0px 0px 10px 5px rgba(255, 255, 255, 0.025),
    /* Длинный подповерхностный эффект */
    inset 0px 0px 40px 5px rgba(255, 255, 255, 0.025);

  /* Позволяем дочерним элементам заполнять родительский */
  position: relative;

  /* Скругляем углы */
  border-radius: 5px;

  /* Скрываем углы заголовка */
  overflow: hidden;
}

.light {
   /* Применяем фоновое изображение */
   background-image: url(path/to/light.png);
   background-repeat: repeat;
   background-size: 750px;

   /* Регулируем яркость */
   opacity: 0.075;

   /* Заполняем пространство фона */
   position: absolute;
   bottom: 0;
   left: 0;
   right: 0;
   top: 0;

   /* Рендерим за другими дочерними элементами */
   z-index: -1;
}

.drag-me {
  /* Центрируем контент */
  display: flex;
  align-items: center;
  justify-content: center;

  /* Задаём размер контента */
  height: 30px;

  /* Добавляем прозрачный фон */
  background-color: rgba(12, 13, 14, 0.75);
}

JS

/**
 * Итеративно обходим элементы 'HTMLElement'с 
 * data-js-background-attachment-fixed и обновляем background-position
 * для симуляции background-attachment: fixed.
 */
const updateDataJSBackgroundAttachmentFixedElements = () => {
  // Находим все элементы с атрибутом data-js-background-attachment-fixed
  const elements = document.querySelectorAll(
    "[data-js-background-attachment-fixed]",
  );

  for (const element of elements) {
    // Обрабатываем только 'HTMLElement'
    if (!(element instanceof HTMLElement)) continue;

    // Находим позицию элемента
    const clientRect = element.getBoundingClientRect();

    // Перемещаем позицию фона противоположно положению вьюпорта
    const backgroundPositionX = `${(-clientRect.x).toString()}px`;
    const backgroundPositionY = `${(-clientRect.y).toString()}px`;

    element.style.backgroundPositionX = backgroundPositionX;
    element.style.backgroundPositionY = backgroundPositionY;
  }
};

/**
 * Начинаем цикл, симулирующий background-attachment: fixed для 
 * 'HTMLElement' при помощи data-js-background-attachment-fixed.
 *
 * Этот цикл исполняется в каждом кадре анимации.
 */
const initDataJSBackgroundAttachmentFixed = () => {
  requestAnimationFrame(() => {
    updateDataJSBackgroundAttachmentFixedElements();
    initDataJSBackgroundAttachmentFixed();
  });
};

initDataJSBackgroundAttachmentFixed();

Версия без JavaScript

Работает не на всех платформах.

HTML

Drag Me

CSS

.glass {
  /* Эффект размытия */
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);

  box-shadow:
    /* Нижний и правый эффект глубины */
    inset -0.75px -0.5px rgba(255, 255, 255, 0.1),
    /* Верхний и левый эффект глубины */
    inset +0.75px +0.5px rgba(255, 255, 255, 0.025),
    /* Эффект тени */
    3px 2px 10px rgba(0, 0, 0, 0.25),
    /* Короткий подповерхностный эффет */
    inset 0px 0px 10px 5px rgba(255, 255, 255, 0.025),
    /* Длинный подповерхностный эффект */
    inset 0px 0px 40px 5px rgba(255, 255, 255, 0.025);

  /* Позволяем дочерним элементам заполнять родительский */
  position: relative;

  /* Скругляем углы */
  border-radius: 5px;

  /* Скрываем углы заголовка */
  overflow: hidden;
}

.glass::before {
  /* Заставляем элемент рендериться */
  content: "";

  /* Применяем фоновое изображение */
  background-image: url(path/to/light.png);
  background-repeat: repeat;
  background-size: 750px;

  /* Регулируем яркость */
  opacity: 0.075;

  /* Заполняем фоновое пространство */
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  top: 0;

  /* Рендерим за другими дочерними элементами */
  z-index: -1;

  /* Исправляем отражение в экран */
  background-attachment: fixed;
}

.drag-me {
  /* Центрируем контент */
  display: flex;
  align-items: center;
  justify-content: center;

  /* Задаём размер контента */
  height: 30px;

  /* Добавляем прозрачный фон */
  background-color: rgba(12, 13, 14, 0.75);
}

А теперь давайте разбираться!

Всю основную работу выполняет backdrop-filter

Секрет хорошего эффекта матового стекла — применение размытия по Гауссу для сокрытия фона, что имитирует внешний вид просвечивающего экрана. Во многих браузерах и игровых движках размытие по Гауссу реализуется при помощи эффективных аппроксимаций наподобие box blur.

В CSS размытие по Гауссу можно применить с помощью backdrop-filter с функцией blur. На iOS требуется -webkit-backdrop-filter , если только вы не ходите забираться так глубоко в настройки, где ещё не ступала нога человека (Settings → Safari → Advanced → Feature Flags → CSS Unprefixed Backdrop Filter). Поддержка backdrop-filter появилась в браузерах лишь недавно, поэтому пользователи Internet Explorer не смогут посмотреть эти демо.

Если соединить всё это вместе, то наше стекло будет простым div:

Мы стилизуем div при помощи класса glass:

.glass {
  /* Эффект размытия */
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
}

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

64cf5396a02f55c9197c0530e1488219.png

Добавляем глубину

Мы создали вполне приемлемый кусок стекла при помощи backdrop-filter: blur(10px). На этом многие туториалы останавливаются, но его можно усовершенствовать! Пока наше стекло выглядит плоским и скучным. У реального стекла есть интересные визуальные особенности по краям, которых здесь недостаёт. Давайте реализуем их.

Края

Сначала добавим к нашему стеклу края. Другие решения, например, css.glass, реализуют их при помощи border: 1px solid. Border влияют на размер элемента и на то, как он взаимодействует с дочерними элементами. Я предпочитаю использовать box-shadow:  inset. При использовании box-shadow: inset дочерние элементы беспроблемно умещаются в области контента стекла без отрицательных margin и прочей магии. Это позволяет им вести себя подобно декалям, нанесённым на поверхность стекла.

box-shadow требует не менее двух значений размеров, а поддерживает до четырёх. Первые два определяют смещения тени по x и y. Для имитации краёв стекла мы используем две просвечивающие белые box-shadow.

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

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

.glass {
  /* Эффект размытия */
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);

  box-shadow:
    /* Нижний и правый эффект глубины */
    inset -0.75px -0.5px rgba(255, 255, 255, 0.1),
    /* Верхний и левый эффект глубины */
    inset +0.75px +0.5px rgba(255, 255, 255, 0.025);
}

4c4ecccd075dbba648baf8d3e4460b3f.png

Реальная тень

Наше стекло теперь имеет приятную глубину, но выглядит неестественно — возник зловещий эффект 3D-объекта, заключённого в 2D-пространство. Чтобы избавиться от него, давайте сделаем так, чтобы стекло как будто физически было поднято над фоном. К счастью, для того есть простой CSS-трюк: традиционная тёмная box-shadow.

Для создания нужного нам эффекта поднятия давайте добавим box-shadow, пропорциональную размеру краёв:

.glass {
  /* Эффект размытия */
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);

  box-shadow:
    /* Нижний и правый эффект размытия */
    inset -0.75px -0.5px rgba(255, 255, 255, 0.1),
    /* Верхний и левый эффект размытия */
    inset +0.75px +0.5px rgba(255, 255, 255, 0.025),
    /* Эффект тени */
    3px 2px 10px rgba(0, 0, 0, 0.25);
}

ed3f21d571380bfeab848a9d6dfd513b.png

Добавляем свет

У нашего стекла есть красивая глубина и мы вполне можем остановиться. Мы уже опередили другие варианты дизайна стёкол, но предстоит ещё многое сделать! Давайте теперь обратим внимание на взаимодействие между стеклом и светом.

Простое подповерхностное рассеивание

Мы начнём играть со светом с добавления аппроксимации подповерхностного рассеивания. Подповерхностное рассеивание (subsurface scattering) — это рассеивание света внутри просвечивающей поверхности. В случае стекла оно заметнее всего рядом с краями.

Для симуляции этого эффекта мы будем использовать box-shadow: inset. Он добавит слабый сбой света, немного проникающий в стекло по краям. Чтобы полностью оценить этот эффект, попробуйте перемещать стекло по тёмной области изображения.

.glass {
  /* Эффект размытия */
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);

  box-shadow:
    /* Нижний и правый эффект глубины */
    inset -0.75px -0.5px rgba(255, 255, 255, 0.1),
    /* Верхний и левый эффект глубины */
    inset +0.75px +0.5px rgba(255, 255, 255, 0.025),
    /* Эффект тени */
    3px 2px 10px rgba(0, 0, 0, 0.25),
    /* Короткий подповерхностный эффект */
    inset 0px 0px 10px 5px rgba(255, 255, 255, 0.025);
}

0b2dd26d0c01b1c91bee88939d7991d7.png

Дополнительное подповерхностное рассеивание

Как видите, подповерхностное рассеивание малозаметно. Чтобы усилить его, давайте добавим другой, более глубокий слой.

.glass {
  /* Эффект размытия */
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);

  box-shadow:
    /* Нижний и правый эффект глубины */
    inset -0.75px -0.5px rgba(255, 255, 255, 0.1),
    /* Верхний и левый эффект глубины */
    inset +0.75px +0.5px rgba(255, 255, 255, 0.025),
    /* Эффект тени */
    3px 2px 10px rgba(0, 0, 0, 0.25),
    /* Короткий подповерхностный эффект */
    inset 0px 0px 10px 5px rgba(255, 255, 255, 0.025),
    /* Длинный подповерхностный эффект */
    inset 0px 0px 40px 5px rgba(255, 255, 255, 0.025);
}

8fd02211ad496b4e3103216eb21143ee.png

Более интересный свет

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

Лучи света

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

535ebf19de8011fda665fcd874b6a109.png

Мы изучим несколько способов смешения этого изображения с фоном. Первый — это задание его в качестве background-image нашего стеклянного элемента. Если делать это непосредственно на элементе, то box-filter размывает background-image, а нам этого не нужно. Вместо этого мы применим background-image к дочернему псевдоэлементу при помощи :: before.

Чтобы псевдоэлемент полностью заполнил родительский элемент, нам нужно позиционировать родителя. Под «позиционированием» элемента подразумевается просто присвоение его свойству позиции чего-то, отличающегося от static (используемого по умолчанию). Чаще всего это position: relative или position: absolute. Для нашего стекла мы используем относительное позиционирование. После позиционирования родительского элемента можно растянуть дочерний, чтобы заполнить его, сделав позицию дочернего элемента абсолютной и присвоив его смещениям краёв (нижнему, левому, правому и верхнему) значение 0.

.glass {
  /* Эффект размытия */
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);

  box-shadow:
    /* Нижний и правый эффект глубины */
    inset -0.75px -0.5px rgba(255, 255, 255, 0.1),
    /* Верхний и левый эффект глубины */
    inset +0.75px +0.5px rgba(255, 255, 255, 0.025),
    /* Эффект тени */
    3px 2px 10px rgba(0, 0, 0, 0.25),
    /* Короткий подповерхностный эффект */
    inset 0px 0px 10px 5px rgba(255, 255, 255, 0.025),
    /* Длинный подповерхностный эффект */
    inset 0px 0px 40px 5px rgba(255, 255, 255, 0.025);

  /* Позволяем дочерним элементам заполнять родительский */
  position: relative;
}

.glass::before {
  /* Заставляем элемент рендериться */
  content: "";

  /* Применяем фоновое изображение */
  background-image: url(path/to/light.png);
  background-repeat: repeat;
  background-size: 750px;

  /* Регулируем яркость */
  opacity: 0.075;

  /* Заполняем фоновое пространство */
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  top: 0;

  /* Рендерим за другими дочерними элементами */
  z-index: -1;
}

3f986efecc214fff8a81f65f25bea3d8.png

Чтобы эффект был заметнее, используйте опцию Toggle Fill Space в оригинале статьи.

8c7fda29fcb997bfc30828daabe46e37.png

Динамический свет

Наши лучи света красиво примешиваются к стеклу, но выглядят на нём статичными. В реальном мире движение стекла через свет создаёт динамические отражения, когда свет скользит по поверхности стекла. К сожалению, воссоздать этот эффект во всех браузерах проблематично. Чтобы сделать это, мы изучим два решения: решение на чистом CSS, работающее на большинстве платформ, за исключением мобильных, и решение на CSS + JavaScript, работающее везде.

Решение на CSS удивительно простое. Мы просто добавляем background-attachement:  fixed к элементу ::before.

.glass::before {
  /* Заставляем элемент рендериться */
  content: "";

  /* Применяем фоновое изображение */
  background-image: url(path/to/light.png);
  background-repeat: repeat;
  background-size: 750px;

  /* Регулируем яркость */
  opacity: 0.075;

  /* Заполняем фоновое пространство */
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  top: 0;

  /* Рендерим за другими дочерними элементами */
  z-index: -1;

  /* Исправляем отражение в экран */
  background-attachment: fixed;
}

114f432c25a189097bfe9560af27ad08.png

Используйте в оригинале статьи опцию Toggle Fill Space и выполните скроллинг или перемещайте стекло, чтобы наблюдать эффект. Помните что поддержка мобильных может быть ограничена; мы решим эту проблему в следующем примере.

Динамическое освещение для всех платформ

Мы воспользуемся JavaScript, чтобы симулировать background-attachment:  fixed на всех платформах. Чтобы получить эффект, мы динамически будем менять background-position при перемещении изображения во вьюпорте. Так как выполнять доступ напрямую к элементам : :before из JavaScript неэффективно, мы применим div.

Наш JavaScript будет нацелен на элементы с определённым атрибутом data-*. Мы воспользуемся атрибутом data-js-background-attachment-fixed.

Соединив всё это вместе, получим два div:

Стилизуем новый дочерний div классом light:

.light {
   /* Применяем фоновое изображение */
   background-image: url(path/to/light.png);
   background-repeat: repeat;
   background-size: 750px;

   /* Регулируем яркость */
   opacity: 0.075;

   /* Заполняем фоновое пространство */
   position: absolute;
   bottom: 0;
   left: 0;
   right: 0;
   top: 0;

   /* Рендерим за другими дочерними элементами */
   z-index: -1;
}

Этот JavaScript обновляет фон:

/**
 * Итеративно обходим элементы 'HTMLElement'с 
 * data-js-background-attachment-fixed и обновляем background-position
 * для симуляции background-attachment: fixed.
 */
const updateDataJSBackgroundAttachmentFixedElements = () => {
  // Находим все элементы с атрибутом data-js-background-attachment-fixed
  const elements = document.querySelectorAll(
    "[data-js-background-attachment-fixed]",
  );

  for (const element of elements) {
    // Обрабатываем только 'HTMLElement'
    if (!(element instanceof HTMLElement)) continue;

    // Находим позицию элемента
    const clientRect = element.getBoundingClientRect();

    // Перемещаем позицию фона противоположно положению вьюпорта
    const backgroundPositionX = `${(-clientRect.x).toString()}px`;
    const backgroundPositionY = `${(-clientRect.y).toString()}px`;

    element.style.backgroundPositionX = backgroundPositionX;
    element.style.backgroundPositionY = backgroundPositionY;
  }
};

/**
 * Начинаем цикл, симулирующий background-attachment: fixed для 
 * 'HTMLElement' при помощи data-js-background-attachment-fixed.
 *
 * Этот цикл исполняется в каждом кадре анимации.
 */
const initDataJSBackgroundAttachmentFixed = () => {
  requestAnimationFrame(() => {
    updateDataJSBackgroundAttachmentFixedElements();
    initDataJSBackgroundAttachmentFixed();
  });
};

initDataJSBackgroundAttachmentFixed();

Мелочи

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

Скруглённые углы

Скруглить угля нашего стекла очень просто, достаточно использовать свойство border-radius.

.glass {
  /* Эффект размытия */
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);

  box-shadow:
    /* Нижний и правый эффект глубины */
    inset -0.75px -0.5px rgba(255, 255, 255, 0.1),
    /* Верхний и левый эффект глубины */
    inset +0.75px +0.5px rgba(255, 255, 255, 0.025),
    /* Эффект тени */
    3px 2px 10px rgba(0, 0, 0, 0.25),
    /* Короткий подповерхностный эффект */
    inset 0px 0px 10px 5px rgba(255, 255, 255, 0.025),
    /* Длинный подповерхностный эффект */
    inset 0px 0px 40px 5px rgba(255, 255, 255, 0.025);

  /* Позволяем дочерним элементам заполнять родительский */
  position: relative;

  /* Скругляем углы */
  border-radius: 5px;
}

55108bceabeb534923c7988029eeb64c.png

Цветное стекло

Чтобы раскрасить стекло, мы добавим поверх один финальный элемент, воспользовавшись background-color со значением альфы для прозрачности. Так как наше стекло имеет скруглённые углы, дочерний элемент будет выходить за поверхность стекла. Чтобы предотвратить это, используем overflow: hidden, чтобы обрезать его.

Вот наш окончательный HTML:

Drag Me

А вот окончательный CSS для стекла и классов drag-me:

.glass {
  /* Эффект размытия */
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);

  box-shadow:
    /* Нижний и правый эффект глубины */
    inset -0.75px -0.5px rgba(255, 255, 255, 0.1),
    /* Верхний и левый эффект глубины */
    inset +0.75px +0.5px rgba(255, 255, 255, 0.025),
    /* Эффект тени */
    3px 2px 10px rgba(0, 0, 0, 0.25),
    /* Короткий подповерхностный эффект */
    inset 0px 0px 10px 5px rgba(255, 255, 255, 0.025),
    /* Длинный подповерхностный эффект */
    inset 0px 0px 40px 5px rgba(255, 255, 255, 0.025);

  /* Позволяем дочерним элементам заполнять родительский */
  position: relative;

  /* Скругляем углы */
  border-radius: 5px;

  /* Скрываем углы заголовка */
  overflow: hidden;
}

.drag-me {
  /* Центрируем контент */
  display: flex;
  align-items: center;
  justify-content: center;

  /* Задаём размер контента */
  height: 30px;

  /* Добавляем прозрачный фон */
  background-color: rgba(12, 13, 14, 0.75);
}

a6ef9438010ead5f5649da364a462dec.png

© Habrahabr.ru