Оживляем UI на мобилках с Sensor API
- понедельник, 6 апреля 2026 г. в 00:00:06
Речь пойдёт о реализации реакции веб-интерфейса на наклон устройства, смещение бликов, теней, для придания ему таким образом интерактивности и объёма.
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 для получения информации о положении в пространстве только на основе суммы данных с гироскопа и акселерометра.
Где-то в 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 возился запомнил уже что к какой оси относится )
Возможно, кому-то, даже этого уже будет достаточно, в целом норм работает
Но мы пойдём дальше.
В отличие от 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()
Если ваш сайт открыт как 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/
ещё на что стоит обратить внимание это опция reduced-motion, можно её проверять прямо в js и НЕ активировать сенсоры если она включена. Где-то в теле функции инициализации, примерно так:
// ... if (window.matchMedia('(prefers-reduced-motion: reduce)')) { // Пользователь предпочитает меньше движений - всё напрасно ))) return false } // init sensors ...
с JS вроде разобрались, теперь перейдём к использованию этих css-переменных и реализации различных эффектов в UI
Итак у нас есть --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/ хочу подчеркнуть что это не готовая библиотека, а вот именно что просто пример реализации который писался только для этой статьи и его обязательно нужно адаптировать под ваш случай.
Надеюсь было интересно. Буду очень благодарен если покидаете ссылки на приложения где как-то необычно используются данные с сенсоров устройства, ну или просто свои идеи что ещё с ними можно сделать.