[Перевод] Как сделать удобное 3D-меню на CSS

image-loader.svg

В новых AR/VR-играх часто заставляют меню как будто парить в воздухе. Воссоздадим основу этого эффекта, добавим адаптивную цветовую схему и учтём пользователей, предпочитающих поменьше анимации. Меню будет работать со скринридером, геймпадом, сенсорным вводом и не только. Подробностями делимся к старту курса по Frontend-разрабтоке.

Обзор

В этом руководстве используются экспериментальные CSS @custom-media и @nest для предотвращения повторения медиазапросов и их размещения в блоках стилей компонентов. Предложенный в этих спецификациях синтаксис поддерживается с помощью PostCSS и этих двух плагинов: postcss-custom-media и postcss-nesting.

HTML

Игровое меню — это список кнопок. В HTML лучше всего представить его так:

Список кнопок хорошо воспринимается технологиями чтения с экрана и работает без JavaScript или CSS.

image-loader.svg

CSS

Стилизация списка кнопок на высоком уровне состоит из:

  • Пользовательских свойств.

  • flex-контейнера.

  • Пользовательской кнопки с декоративными псевдоэлементами.

  • Размещения элементов в 3D.

Обзор пользовательских свойств

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

Ниже приведены медиазапросы, также известные как пользовательские медиа. Они сохранены как переменные CSS. Они глобальны и для сохранения краткости и читаемости кода будут использоваться в различных селекторах. Компонент игрового меню использует prefers-reduced-motion, системную цветовую схему и возможности цветового диапазона дисплея:

@custom-media --motionOK (prefers-reduced-motion: no-preference);
@custom-media --dark (prefers-color-scheme: dark);
@custom-media --HDcolor (dynamic-range: high);

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

В следующем соглашении об именовании переменных используются стратегии, о которых рассказывается в этом посте Леа Вероу:

.threeD-button-set {
  --y:;
  --x:;
  --distance: 1px;
  --theme: hsl(180 100% 50%);
  --theme-bg: hsl(180 100% 50% / 25%);
  --theme-bg-hover: hsl(180 100% 50% / 40%);
  --theme-text: white;
  --theme-shadow: hsl(180 100% 10% / 25%);

  --_max-rotateY: 10deg;
  --_max-rotateX: 15deg;
  --_btn-bg: var(--theme-bg);
  --_btn-bg-hover: var(--theme-bg-hover);
  --_btn-text: var(--theme-text);
  --_btn-text-shadow: var(--theme-shadow);
  --_bounce-ease: cubic-bezier(.5, 1.75, .75, 1.25);

  @media (--dark) {
    --theme: hsl(255 53% 50%);
    --theme-bg: hsl(255 53% 71% / 25%);
    --theme-bg-hover: hsl(255 53% 50% / 40%);
    --theme-shadow: hsl(255 53% 10% / 25%);
  }

  @media (--HDcolor) {
    @supports (color: color(display-p3 0 0 0)) {
      --theme: color(display-p3 .4 0 .9);
    }
  }
}

Темы и конический градиент

Светлая тема имеет яркий конический градиент от голубого до лилового, а тёмная тема имеет тёмный тонкий конический градиент. Чтобы узнать больше о том, что можно сделать с коническими градиентами, смотрите conic.style:

html {
  background: conic-gradient(at -10% 50%, deeppink, cyan);

  @media (--dark) {
    background: conic-gradient(at -10% 50%, #212529, 50%, #495057, #212529);
  }
}

Включение трёхмерной перспективы

Чтобы элементы находились в трёхмерном пространстве веб-страницы, необходимо инициализировать viewport с перспективой. Я решил поместить перспективу в элемент body и использовал единицы viewport:

body {
  perspective: 40vw;
}

Вот какое влияние оказывает перспектива.

Стилизация списка кнопок

    Этот элемент отвечает за общую компоновку макроса списка кнопок, а также является интерактивной, плавающей в пространстве карточкой.

    Макет группы кнопок

    Flexbox может управлять макетом контейнера. Измените направление flex по умолчанию со строк на столбцы через свойство flex-direction и предоставьте каждому элементу размер в соответствии с его содержимым, установив align-items: flex-start:

    .threeD-button-set {
      /* remove 
      margins */ margin: 0; /* vertical rag-right layout */ display: flex; flex-direction: column; align-items: flex-start; gap: 2.5vh; }

    Установите контейнер как контекст трёхмерного пространства и настройте CSS-функцию clamp (), чтобы карточка не поворачивалась за пределы видимости. Обратите внимание, что среднее значение для clamp — пользовательское свойство, эти значения (--x и --y) будут установлены из JavaScript позже при взаимодействии с мышью:

    .threeD-button-set {
      …
    
      /* create 3D space context */
      transform-style: preserve-3d;
    
      /* clamped menu rotation to not be too extreme */
      transform:
        rotateY(
          clamp(
            calc(var(--_max-rotateY) * -1),
            var(--y),
            var(--_max-rotateY)
          )
        )
        rotateX(
          clamp(
            calc(var(--_max-rotateX) * -1),
            var(--x),
            var(--_max-rotateX)
          )
        )
      ;
    }

    Далее, если движение устраивает посетителя сайта, добавьте подсказку браузеру, что трансформация этого элемента будет постоянно меняться с помощью will-change. Кроме того, включите интерполяцию, установив переход для трансформаций. 

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

    @media (--motionOK) {
      .threeD-button-set {
        /* browser hint so it can be prepared and optimized */
        will-change: transform;
    
        /* transition transform style changes and run an infinite animation */
        transition: transform .1s ease;
        animation: rotate-y 5s ease-in-out infinite;
      }
    }

    Анимация rotate-y устанавливает только средний keyframe на 50%, поскольку браузер по умолчанию устанавливает 0 и 100% в соответствии со стилем элемента по умолчанию. 

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

    @keyframes rotate-y {
      50% {
        transform: rotateY(15deg) rotateX(-6deg);
      }
    }

    Стилизация элементов
  • Каждый элемент списка (

  • ) содержит кнопку и элементы её границы. Стиль отображения изменяется, чтобы элемент не показывал :: marker. Свойство position установлено на relative, чтобы предстоящие псевдоэлементы кнопки позиционировались в пределах всей области, которую занимает кнопка:

    .threeD-button-set > li {
      /* change display type from list-item */
      display: inline-flex;
    
      /* create context for button pseudos */
      position: relative;
    
      /* create 3D space context */
      transform-style: preserve-3d;
    }

    image-loader.svg

    Стилизация элементов

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

    Основные стили

    Ниже приведены основные стили других состояний:

    .threeD-button-set button {
      /* strip out default button styles */
      appearance: none;
      outline: none;
      border: none;
    
      /* bring in brand styles via props */
      background-color: var(--_btn-bg);
      color: var(--_btn-text);
      text-shadow: 0 1px 1px var(--_btn-text-shadow);
    
      /* large text rounded corner and padded*/
      font-size: 5vmin;
      font-family: Audiowide;
      padding-block: .75ch;
      padding-inline: 2ch;
      border-radius: 5px 20px;
    }

    image-loader.svg

    Псевдоэлементы кнопок 

    Границы кнопки — это не стандартные границы, а псевдоэлементы с position: absolute и границами.

    image-loader.svg

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

    .threeD-button button {
      …
    
      &::after,
      &::before {
        /* create empty element */
        content: '';
        opacity: .8;
    
        /* cover the parent (button) */
        position: absolute;
        inset: 0;
    
        /* style the element for border accents */
        border: 1px solid var(--theme);
        border-radius: 5px 20px;
      }
    
      /* exceptions for one of the pseudo elements */
      /* this will be pushed back (3x) and have a thicker border */
      &::before {
        border-width: 3px;
    
        /* in dark mode, it glows! */
        @media (--dark) {
          box-shadow:
            0 0 25px var(--theme),
            inset 0 0 25px var(--theme);
        }
      }
    }

    Стили 3D-transform

    Ниже transform-style установлен на preserve-3d, чтобы дочерние элементы могли располагаться по оси z. Для transform установлено пользовательское свойство --distance. Эта дистанция будет увеличиваться при наведении и фокусировке:

    .threeD-button-set button {
      …
    
      transform: translateZ(var(--distance));
      transform-style: preserve-3d;
    
      &::after {
        /* pull forward in Z space with a 3x multiplier */
        transform: translateZ(calc(var(--distance) / 3));
      }
    
      &::before {
        /* push back in Z space with a 3x multiplier */
        transform: translateZ(calc(var(--distance) / 3 * -1));
      }
    }

    Стили анимации по условию

    Если пользователя устраивает движение, кнопка намекает браузеру, что свойство transform должно быть готово к изменению: для свойств transform и background-color устанавливается transition. Обратите внимание на разницу в длительности: мне показалось, что это даёт хороший, тонкий ступенчатый эффект:

    .threeD-button-set button {
      …
    
      @media (--motionOK) {
        will-change: transform;
        transition:
          transform .2s ease,
          background-color .5s ease
        ;
    
        &::before,
        &::after {
          transition: transform .1s ease-out;
        }
    
        &::after    { transition-duration: .5s }
        &::before { transition-duration: .3s }
      }
    }

    Стили взаимодействия Hover и Focus

    Цель анимации взаимодействия — раздвинуть слои, из которых состоит плоская кнопка. Она достигается установкой переменной --distance, первоначально равной 1 px. Селектор, показанный в следующем примере кода, проверяет фокус и наведение. И, если кнопка в фокусе или курсор наведён на кнопку, применяет CSS, чтобы сделать следующее:

    • применить цвет фона наведения;

    • увеличить расстояние;

    • добавить эффект облегчения отскока;

    • ускорить переходы псевдоэлементов.

    Вариация transition-duration используется только при наведении, ступенчатость анимации применяется только при наведении. При снятии наведения или фокуса каждый слой переходит к стандартному состоянию:

    .threeD-button-set button {
      …
    
      &:is(:hover, :focus-visible):not(:active) {
        /* subtle distance plus bg color change on hover/focus */
        --distance: 15px;
        background-color: var(--_btn-bg-hover);
    
        /* if motion is OK, setup transitions and increase distance */
        @media (--motionOK) {
          --distance: 3vmax;
    
          transition-timing-function: var(--_bounce-ease);
          transition-duration: .4s;
    
          &::after  { transition-duration: .5s }
          &::before { transition-duration: .3s }
        }
      }
    }

    3D-перспектива хороша, если пользователь предпочитает поменьше анимации. Верхний и нижний элементы показывают приятный эффект.

    Небольшие улучшения с помощью JavaScript

    Интерфейс уже можно использовать с клавиатуры, скринридеров, геймпада, сенсорного ввода и мыши, но мы можем добавить несколько лёгких штрихов JavaScript.

    Поддержка клавиш со стрелками

    Клавиша табуляции — отличный способ навигации по меню, но хотелось бы, чтобы на геймпаде фокус перемещался с помощью курсора или джойстиков. Библиотека roving-ux, часто используемая для интерфейсов GUI Challenge, будет обрабатывать клавиши со стрелками. Приведённый ниже код указывает библиотеке перехватить фокус внутри .threeD-button-set и перенаправить его на дочерние кнопки:

    import {rovingIndex} from 'roving-ux'
    
    rovingIndex({
      element: document.querySelector('.threeD-button-set'),
      target: 'button',
    })

    Параллаксное взаимодействие мыши

    Отслеживание мыши и наклон меню призваны имитировать интерфейсы видеоигр AR и VR, где вместо мыши у вас может быть виртуальный указатель. Это может быть забавно, когда элементы хорошо осведомлены об указателе.

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

    const menu = document.querySelector('.threeD-button-set')
    const menuRect = menu.getBoundingClientRect()
    
    const { matches:motionOK } = window.matchMedia(
      '(prefers-reduced-motion: no-preference)'
    )

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

    const getAngles = (clientX, clientY) => {
      const { x, y, width, height } = menuRect
    
      const dx = clientX - (x + 0.5 * width)
      const dy = clientY - (y + 0.5 * height)
    
      return {dx,dy}
    }

    Следите за движением мыши, передавайте позицию в функцию getAngles () и используйте значения дельты в качестве стилей пользовательских свойств. Я разделил на 20, чтобы сгладить дельту и сделать её менее дёрганой, возможно, есть лучший способ сделать это. Как вы помните, в самом начале мы поместили реквизиты --x и --y в середину функции clamp (), это предотвращает чрезмерный поворот карточки в нечитаемое положение.

    if (motionOK) {
      window.addEventListener('mousemove', ({target, clientX, clientY}) => {
        const {dx,dy} = getAngles(clientX, clientY)
    
        menu.attributeStyleMap.set('--x', `${dy / 20}deg`)
        menu.attributeStyleMap.set('--y', `${dx / 20}deg`)
      })
    }

    Перевод и направления письма

    При тестировании игрового меню в других режимах написания и языках возникла одна загвоздка.

    Элементы