Речь пойдёт о реализации реакции веб-интерфейса на наклон устройства, смещение бликов, теней, для придания ему таким образом интерактивности и объёма.

Device Orientation API существует уже давно, мобильные устройства с гироскопом стали основным окном для приложений и сайтов, в тренде эмоциональный дизайн, всевозможные эффекты "блеска" / градиентов встречаются повсеместно, и кажется пора это всё объединить!
И ведь Apple выкатили эту фишку в liquid glass! Но... лично по моему мнению, как-то не "дожали" или она померкла на фоне других нововведений... а жаль, я считаю реакцию ui на положение устройства гораздо более перспективной темой чем новая прозрачность с крутой физикой преломлений которую тут же все побежали повторять. В отличие от преломления фона, адекватная реакция на наклон устройства это не графон ради графона, а микро‑взаимодействие дающее ощущение контроля, отзывчивости, даже "живости" интерфейса. Ведь даже если пользователь не тапает по экрану - он очень даже взаимодействует с интерфейсом(смотрит/читает) и слегка "покачивает" телефон в руке, и UI на эти микродвижения уже чуть-чуть отвечает, маленькая физика (свет/тень/глубина), как будто элементы не нарисованы, а существуют как объекты... Ну это моё субъективное восприятие... тут есть похожие мысли про роль микровзаимодействий и баланс эмоций.
Знаю что некоторых людей "лишние" анимации наоборот нервируют, или даже "укачивают", чтож... прекрасно что для них есть опция reduce-motion, для меня такой замечательной обратной опции "сделать красиво" нет )))
Если всё ещё не понятно о чём я, можете глянуть это видео(на youtube):

Видео демонстрация

Обратите внимания на блики на ребрах карточки, легкий движущийся градиент на фоне чекбокса и блики на иконках конечно.
На своём мобильном устройстве, эффект можно посмотреть в миниаппе телеграм (лучше в тёмной теме, там заметнее). Есть ещё вот такое демо и оно же внутри телеги.

Казалось бы, на этом можно и закончить, дать ссылочки на доки swift, android, MDN - всё, api у вас есть - берите инфу с датчиков - делайте красотульку! Но дьявол кроется в деталях, в которых и предлагаю сейчас шаг за шагом разобраться.

"Торопыги" могут сразу посмотреть полный код реализации на js
Вайбкодеры - просто вставьте этот промпт:

Добавь эффекты блеска UI, реагирующие на наклон устройства.
Инструкция: https://raw.githubusercontent.com/alexstep/sensor/main/AI.md

Ну а всех кому интересны подробности приглашаю под кат и в комментарии.


Датчики

В современных устройствах установлено куча всевозможных МЭМС (кстати микроэлектромеханические системы прям отдельная интересная тема, посмотрите например как именно устроен современный акселерометр).
В спецификации свежего iphone помимо Face ID и LiDAR можно увидеть:

  • Barometer (Барометр, измеряет атмосферное давление для определения высоты)

  • High dynamic range gyro (Трёхосевой гироскоп для отслеживания ориентации, стабилизации изображения)

  • High-g accelerometer (акселерометр, обнаруживает ускорение, повороты, шаги)

  • Proximity sensor (Датчик приближения, отключает экран при приближении к лицу во время звонка)

  • Dual ambient light sensors (датчики освещённости, регулируют яркость экрана)

  • Magnetometer (Магнитометр он же компас, определяет магнитное поле для навигации) В pixel нет LiDAR и FaceID, зато есть сканер отпечатков и дополнительно

  • Temperature (Инфракрасный датчик температуры для измерения температуры объектов)

  • Hall effect (Для обнаружения чехлов)

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

  • гироскоп

  • акселерометр

  • магнитометр - его намеренно проигнорируем

Самое главное что нужно знать фронтендеру про эти датчики, это то что они суперчувствительны, подвержены помехам и при кривом использовании жрут батарею и грузят основной процесс тонной событий. Так что чтобы ваш интерфейс не "дрожал", не "скакал" и не лагал, нужно будет все эти их особенности учесть. Но - всё это достаточно легко решаемо.
Вот интересная статья про "капризы" сенсоров.
Собственно от магнитометра мы отказываемся именно из-за помех. Прекрасно что есть готовые api для получения информации о положении в пространстве только на основе суммы данных с гироскопа и акселерометра.

API

Где-то в 2010-2011 в Mobile Safari и Android Browser появились DeviceOrientation / DeviceMotion позже присоединился и firefox. Появилась классная либа gyro.js , помню именно на ней тогда сделал весьма посредственный эффект перспективы на своём персональном сайте который с тех пор толком не обновлял. Собственно наличие такой библиотеки как-бы намекает на некоторые недостатки API. Там нет возможности установать частоту обновления, значения какие-то нестабильные, нельзя отдельно с разных сенсоров получать данные, выдается только вместе гиро + аксель, на данный момент только это api доступно в safari
Ну примерно вот так:

window.addEventListener('deviceorientation', throttle(function(move){
  console.log(move.alpha, move.beta, move.gamma)
}, 111))

В 2016-2018 W3C решает переделать всё правильно и появляется Generic Sensor API

const sensor = new RelativeOrientationSensor({ frequency: 60 })
sensor.addEventListener("reading", () => {
  const [x, y, z, w] = sensor.quaternion
})
sensor.start()

но Apple отказывается его поддерживать из-за privacy/security/fingerprinting и вообще "нам хватает DeviceOrientation" и плюс ко всему(видимо для подтверждения серьезности этих заявлений) они в 2019 году в ios 13 они начинают требовать явное разрешение пользователя DeviceOrientationEvent.requestPermission() которое выдается только на одну короткую сессию , то есть при повторном заходе на сайт - снова надо спрашивать 🥺
Возможно именно это стало причиной того что всевозможные эффекты на основе гироскопа даже не успели зародиться в вебе...
Так что когда Telegram в свои мини-приложения прокинул нативные данные с датчиков счастью моему не было предела!

window.Telegram?.WebApp.DeviceOrientation.start({ refresh_rate: 42 })

работает на ios без всяких запросов и подтверждений! Там у него и accelerometer и gyroscope и общее DeviceOrientation!

И конечно же если у нас нативное приложение или сайт внутри WebView то мы можем использовать API платформы:

  • Coremotion для IOS

  • SensorManager в Android и прокидывать в webview показания (я Swift не знаю, но ИИ подсказывает что это делается как-то так):

let motionManager = CMMotionManager()
motionManager.deviceMotionUpdateInterval = 1.0 / 30.0 // ~33ms/30Hz
// @TODO: check battery status > 50%
motionManager.startDeviceMotionUpdates(to: .main) { motion, error in
  guard let motion = motion else { return }

  let q = motion.attitude.quaternion

  let payload: [String: Any] = [
	"type": "relative-orientation",
	"quaternion": [q.x, q.y, q.z, q.w],
	"timestamp": Int(Date().timeIntervalSince1970 * 1000),
	"source": "native-ios"
  ]
	
  let json = try! JSONSerialization.data(withJSONObject: payload)
  let jsonString = String(data: json, encoding: .utf8)!
	
  let js = "window.__onMotion(\(jsonString));"
  webView.evaluateJavaScript(js)
}

Можно посмотреть исходники телеграм где он прокидывает в WebView данные сенсоров в его android клиенте и в ios версии

Да в итоге мы в ситуации когда есть ворох из разных api разного уровня качества и поддержки...
И всё же на сегодняшний день у нас только одно ограничение - запрос разрешения доступа для сайта на ios. Если ваш сайт уже для каких-то целей его запрашивает - то и этого ограничения у ваc нет. У нас огромное количество приложений запускается в WebView и там свободно можно применять подобные эффекты. +если что-то не поддерживается в ios это не повод теперь не делать классно для пользователей android.

Батарея

Расход батареи конечно становится больше при подключении сенсоров, при чём скорее даже из-за JS, а не из-за того что сам датчик начинает потреблять энергию, сами по себе устройства гироскопа и акселерометра достаточно энергоэффективны и потребляют 1-3 мА·ч, для сравнения подсветка экрана на минимальной яркости ~20–50 мА·ч, gps - 30–60 мА·ч, wi-fi ~10–200 мА·ч при активной передачи данных). Повышенный расход энергии может быть из-за накладных расходов на обработку данных с сенсоров.
Так что самое главное - сразу настроить адекватную частоту опроса датчика. В целом в нашем кейсе не должно быть проблем с излишним потреблением батареи.
Но, раз уж у нас это всё просто "украшательства" давайте добавим в код

if ((await navigator.getBattery?.())?.level < 0.5) return

Отладка

HTTPS - важно вам нужно защищенное соединение, просто на "localhost" браузер не отдаст данные. Так что проверяйте на реальном домене или используйте сервисы типа ngrok / locatunnel для прокидывания локального порта на домен с https.

В ChromeDevTools есть панелька в которой можно выставить координаты сенсоров, нажимаете Cmd+Shift+P - пишете "sensor" - выбираете пункт показать сенсоры. В safari такого нет, но всё равно лучше всегда отлаживать на реальном устройстве, подключайте его в дебаг режиме крутите-вертите. Только держа телефон в руке вы сможете заметить все лишние "дрожания" или наоборот тормоза.

Реализация

Если коротко: берём с датчиков 2 координаты соотносящиеся с условной горизонталью и вертикалью, приводим их к формату 0-100(%) и устанавливаем как css переменные, которые используем в calc() для расчёта transform элементов

transform: translateY(calc(60% - var(--gyro-gamma-percent) * 2%));

Давайте начнем с самого древнего и поддерживаемого везде api, позже это останется фоллбэком для safari

<section id="test"></section>
<script defer>
// пока что совсем базово
window.addEventListener('deviceorientation', e => {
  document.documentElement.style.setProperty('--gyro-gamma', e.gamma)
  document.documentElement.style.setProperty('--gyro-beta', e.beta)
  
  console.log(e)
})
</script>
<style>
:root {
  --gyro-gamma: 50; --gyro-beta: 50;
}
section {
  position:relative;
  margin: 40px auto;
  width: 70%; height: 50%;
  background: #333;
  &::before {
    content: "";
    position: absolute;
    border-radius: 100%;
    top: 50%; left: 50%;
    width: 10px; height: 10px;
    background: yellow;
    transform: translate(calc(var(--gyro-gamma) * 1px), calc(var(--gyro-beta) * 1px)); 
  }
}
</style>

Худо-бедно работает(в Chrome) - посмотрите на мобилке(нужен android), "точка" двигается при наклоне.
Давайте сразу разберемся с safari чтобы можно было в нём тестить, допишем:

document.querySelector('#test').addEventListener('click', async ()=>{
  if (typeof DeviceOrientationEvent?.requestPermission !== 'function') return

  const result = await DeviceOrientationEvent.requestPermission()
  console.log(result)
})

Событий deviceorientation очень много, поэтому добавим тротлинг

function throttle(fn, ms) {
  let last = 0;
  return (...args) => {
    const now = performance.now()
    if (now - last < ms) return
    last = now
    fn(...args)
  }
}

const handler = throttle(e => {
  if (e.beta == null || e.gamma == null) return
  document.documentElement.style.setProperty('--gyro-gamma', e.gamma)
  document.documentElement.style.setProperty('--gyro-beta', e.beta)
}, 50)

window.addEventListener('deviceorientation', handler)

50 мс, примерно 20 обновлений в секунду, для бликов вроде хватает, можно будет поэкспериментировать позже.

Данные приходят но в css использовать "cырые" углы beta 0-180, gamma -90-90 не особо удобно, так что давайте сразу переводить всё в диапазон 0–100, где 50 - нейтраль:

const GAMMA_RANGE = 70 // gamma до 90, но на краях мусор — режем
const BETA_OFFSET = 45 // beta 45° = "телефон в руке"
const BETA_RANGE = 45

const handler = throttle(e => {
  if (e.beta == null || e.gamma == null) return
  if (e.beta > 90) return // экран вниз — игнорим
  
  const gammaNorm = clamp(e.gamma / GAMMA_RANGE)
  const betaNorm = clamp((e.beta - BETA_OFFSET) / BETA_RANGE)
  const gammaPercent = ((gammaNorm + 1) * 50).toFixed(2)
  const betaPercent = ((betaNorm + 1) * 50).toFixed(2)
  
  document.documentElement.style.setProperty('--gyro-gamma-percent', gammaPercent)
  document.documentElement.style.setProperty('--gyro-beta-percent', betaPercent)
}, 50)


function clamp(v, min = -1, max = 1) {
  return Math.min(max, Math.max(min, v))
}

Константы подбирал эмпирически

теперь у нас в css есть --gyro-gamma-percent и --gyro-beta-percent меняющиеся в диапазоне от 0 до 100, что значительно упрощает логику, в самом css теперь можно абстрагироваться от реальных углов, 50 это середина, ну и 0 и 100 - края, удобно делать всякие сдвиги на % от ширины контейнера. Разве что можно добавить ещё offset от центра (-50..50), я его часто использую в коде:

:root {
  --gyro-gamma-percent: 50; --gyro-beta-percent: 50;

  --g-offset: calc(var(--gyro-gamma-percent) - 50);
  --b-offset: calc(var(--gyro-beta-percent) - 50);
}

конечно вы можете назвать переменные по своему например --g-horizontal вместо gamma и --g-vertical вместо beta, я пока c js возился запомнил уже что к какой оси относится )

Возможно, кому-то, даже этого уже будет достаточно, в целом норм работает

Но мы пойдём дальше.

RelativeOrientationSensor

В отличие от deviceorientation в современном эйпиай можно задать frequency/реальную частоту опроса датчика, и обойтись без тротлер-функции поверх.

if (!window.RelativeOrientationSensor) { /* fallback на deviceorientation */ return }

const sensor = new RelativeOrientationSensor({ 
  frequency: 42, 
  referenceFrame: "screen" 
})

sensor.addEventListener('reading', () => {
  const [qx, qy, qz, qw] = sensor.quaternion
  // ...
})
sensor.start()

этот сенсор отдаёт нам не просто "массивчик" из 4х чисел, а Кватернион! как сообщает Википедия

Кватернио́ны (от лат. quaterni, по четыре) — система гиперкомплексных чисел, образующая векторное пространство размерностью четыре над полем вещественных чисел.

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

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

Не знаю... может это не лучшее решение, но я через вектор гравитации в итоге привожу эти координаты к такому же формату как у deviceorientation, делаю те же gamma и beta, для совместимости так сказать:

// сначала получим вектора гравитации из кватериона
const gx = 2 * (qx * qz - qw * qy)
const gy = 2 * (qy * qz + qw * qx)
// а из них уже наши бету и гамма
const betaDeg = Math.asin(clamp(gy)) * (180 / Math.PI)
const gravityGammaDeg = Math.asin(clamp(gx)) * (180 / Math.PI)

всё вместе c нормализацией:

const GAMMA_RANGE = 70, BETA_OFFSET = 45, BETA_RANGE = 45; // Диапазоны нормализации углов, подобраны эмпирически
const clamp = (value, min = -1, max = 1) => Math.min(max, Math.max(min, value))
const RAD2DEG = 180 / Math.PI

sensor.addEventListener('reading', () => {
  const [qx, qy, qz, qw] = sensor.quaternion
  const gx = 2 * (qx * qz - qw * qy)
  const gy = 2 * (qy * qz + qw * qx)
  const betaDeg = Math.asin(clamp(gy)) * RAD2DEG
  const betaNorm = clamp((betaDeg - BETA_OFFSET) / BETA_RANGE)
  const gravityGammaDeg = Math.asin(clamp(gx)) * RAD2DEG
  const gravityGamma = clamp(-gravityGammaDeg / GAMMA_RANGE)
})
sensor.start()

API Telegram Mini Apps

Если ваш сайт открыт как Telegram Mini App, можно использовать DeviceOrientation из WebApp API. На iOS работает без запроса разрешения так как Telegram уже получил доступ к сенсорам.

function initTWASensor() {
  const TWA = window.Telegram?.WebApp
  
  if (!TWA?.DeviceOrientation || !['ios', 'android'].includes(TWA.platform)) {
    return false
  }

  TWA.DeviceOrientation.start({
    refresh_rate: 42, 
    need_absolute: false
  })

  TWA.onEvent('deviceOrientationChanged', () => {
  	const { gamma, beta } = TWA.DeviceOrientation
  	document.documentElement.style.setProperty("--gyro-gamma",clamp(gamma))
  	document.documentElement.style.setProperty("--gyro-beta",clamp(beta))
  })

  return true
}

в telegram как видите всё намного проще и вы можете повторить его подход в своих webview, а для react native есть https://react-native-sensors.github.io/

reduce-motion

ещё на что стоит обратить внимание это опция reduced-motion, можно её проверять прямо в js и НЕ активировать сенсоры если она включена. Где-то в теле функции инициализации, примерно так:

// ...
if (window.matchMedia('(prefers-reduced-motion: reduce)')) {
  // Пользователь предпочитает меньше движений - всё напрасно ))) 
  return false
}
// init sensors ...

с JS вроде разобрались, теперь перейдём к использованию этих css-переменных и реализации различных эффектов в UI

CSS эффекты

Итак у нас есть --gyro-gamma и --gyro-beta cо значениями от 0 до 100 где 50 это некая "середина"/"нормальное"/"дефолтное" положение. Теперь нам нужно добавить эти переменные в calc(.. для вычисления позиционирования наших (псевдо)элементов, типа

transform: translateX(calc(var(--gyro-gamma) * 1%)) translateY(calc(var(--gyro-beta) * 0.2%));

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

Блики

Сначала надо бы сделать "карточку"/блок с гранями(border) например так:

section {
  position:relative; 
  overflow: hidden;
  background: #fff;
  border-top: 1px solid #fff;
  border-right: 1px solid #e9e9e9;
  border-left: 1px solid #e9e9e9;
  border-bottom: 1px solid #dadada;
  box-shadow:
    0 15px 20px rgba(0, 0, 0, 0.05),
    0px 5px 100px rgba(0, 0, 0, 0.05) inset;
  border-radius: 10px;
  corner-shape: superellipse(1);
  

теперь добавим внутрь неё "блик" яркую белую точку по правой границе

section::after {
  content: ""; display: block; position: absolute; pointer-events: none;

  /* позиционируем блик примерно по середике по вертикали */
  top: 20%; right: 0; 
  height: 60%; width: 1px;

  /* на фон ставим градиент от почти прозрачного цвета
  до белого */
  background: linear-gradient(to top, #0000 10%, #fff1 40%, #fff 55%, #fff1 70%, #0000 90%);
}

осталось начать его двигать вместе с устройством

/* не забываем про reduce motion */
@media (prefers-reduced-motion: no-preference) {
section::after {
  /* можно сразу вынести некоторые параметры в переменные 
   чтобы удобнее было "тюнить" эффект, смотреть на телефоне 
   и подгонять скорость/заметность
  */
  --edge-speed: -2.5%; /* скорость скольжения */

  /* тут мы переводим диапазон от0до100 в от-50до50 */
  --b-offset: calc(var(--gyro-beta) - 50);

  /* ну и двигаем по вертикали в зависимости от того как наклонен телефон */  
  transform: translateY(calc(var(--b-offset) * var(--edge-speed)));
  will-change: transform;
}

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

Вот ещё один пример, теперь это блик поверх иконки, допустим у нас такой html

<figure class="icon"><svg>...</svg></figure>

мы добавляем

figure.icon::before {
  display: block;
  content: "";
  pointer-events: none;
  position: absolute;
  z-index: 2;
  top: 0;
  width: 64px;
  height: 64px;
  left: calc(160px + var(--gyro-gamma-percent) * -3px);
  background: linear-gradient(90deg, #0000 20%, #fff6 38%, #0000 62%);
}

результат как на первом видео в этом посте.

Или вот например у нас checkbox в старом стиле apple

input[type="checkbox"].toggle {
  appearance: none;
  cursor: pointer;
  display: inline-block;
  background: #ccc;
  border-radius: 16px;
  corner-shape: superellipse(1);
  width: 50px;
  height: 28px;
  overflow: hidden;
  position: relative;
  vertical-align: middle;
  transition: background 0.25s;
  border-top: 1px solid #aaa;
  &:before,
  &:after {
    content: "";
  }
  &:before {
    display: block;
    background: #fefefe;
    filter: saturate(2);
    box-shadow: 0 2px 0 rgba(0, 0, 0, 0.08);
    border-radius: 50%;
    height: calc(100% - 4px);
    aspect-ratio: 1;
    position: absolute;
    z-index:1;
    top: 1.5px;
    left: 2px;
    transition: transform 0.25s;
  }

  &:checked {
    background: #66cc67;
    &:before {
      transform: translateX(calc(100% - 2px));
    }
  }
}

можно также добавить ему плавающий градиент на фон

@media (prefers-reduced-motion: no-preference) {

input[type="checkbox"].toggle::after {
  display: block;
  content: "";
  pointer-events: none;
  position: absolute;
  z-index: 0;
  top: 0;
  width: 100px;
  height: 28px;
  left: calc(160px + var(--gyro-gamma-percent) * -3px);
  background: linear-gradient(90deg, #0000 20%, #fff5 38%, #0000 62%);
}

Больше css-рецептов можно посмотреть по ссылке, я постарался там собрать самые ненавязчивые эффекты чтобы AI не превратил UI в "новогоднюю ёлку" )

Заключение

Весь код и примеры есть в репозитории https://github.com/alexstep/sensor/ хочу подчеркнуть что это не готовая библиотека, а вот именно что просто пример реализации который писался только для этой статьи и его обязательно нужно адаптировать под ваш случай.

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