У нас в голосовых комнатах на аватарки можно надевать шляпы. Их четырнадцать штук: корона, каска, колпак повара, ковбойская, санта, цилиндр, панама, кепка и так далее. Пока человек говорит, шляпа чуть покачивается — чтобы не выглядела наклейкой. Деталь маленькая, но мне нравилась.

Потом пришёл баг: «шляпа иногда прокручивается вокруг своей оси, как пропеллер, и замирает». С видео. На видео ковбойская шляпа делала полный оборот и вставала на место, будто ничего не было.

Одну деталь из репорта придержите — она в итоге и оказалась разгадкой. Жаловались на ковбойскую и на колпак повара. На корону и каску — нет.

Открываю компонент

AvatarHats.tsx. Наклон устроен просто: пока isSpeaking, раз в 400–700 мс берётся новый случайный угол в пределах ±3°, и шляпа к нему едет. Плавность — на пружине Framer Motion, и у каждой шляпы своя жёсткость, я их подбирал руками:

crown:   springStiffness: 150, springDamping: 12   // корона сидит жёстко
hardhat: springStiffness: 200, springDamping: 15   // каска почти не шевелится
cowboy:  springStiffness: 70,  springDamping: 7     // поля мягко колышутся
chef:    springStiffness: 60,  springDamping: 6     // высокий колпак гуляет сильнее всех

Вот эти маленькие damping у мягких шляп — уже проблема, просто тогда я так на них не смотрел.

Была ещё одна вещь. Поверх пружины Framer жил мой собственный цикл на requestAnimationFrame, который тоже 60 раз в секунду догонял целевой угол — своей самодельной «пружиной». Примерно так (не повторяйте):

useEffect(() => {
  let frame;
  const animate = () => {
    setRotation(prev => {
      const diff = target - prev;
      const v = diff * (stiffness / 1000);
      return prev + v * (1 - damping / 100);
    });
    frame = requestAnimationFrame(animate);
  };
  frame = requestAnimationFrame(animate);
  return () => cancelAnimationFrame(frame);
}, [target]);

И это значение, пересчитанное на каждом кадре, я отдавал в animate={{ rotate }} у Framer Motion, а там своя пружина. То есть на один угол работали две пружины одновременно. Зачем так вышло — уже не помню; похоже, сначала был только rAF, потом переехал на Framer, а старое не выпилил.

Сначала грешил на своё

Логично было подумать на rAF-цикл: он же двигает угол вручную, вот пусть он и разгоняет. Воткнул в него console.log. Нет — значение честно болтается в пределах ±3° и никуда не улетает. Сам по себе цикл не крутит.

Крутила связка.

Почему обороты копил именно Framer

Пружина у Framer — это не «плавно доехать до числа». Это физика: у пружины есть скорость и инерция. Она рассчитана на переход между состояниями — было rotate: 0, стало rotate: 10, доехала с лёгким перелётом и успокоилась.

А я менял ей цель каждый кадр. На каждом рендере Framer перенацеливал пружину на новую точку — но текущую скорость при этом не обнулял, она накапливалась. Пока мой rAF полз в одну сторону, пружина в ту же сторону разгонялась. Гасить разгон было нечем: у мягких шляп damping я специально занизил, чтобы поля ковбойской красиво гуляли.

Вот тут и сходится та деталь из репорта. Крутились ковбойская (damping 7) и колпак (damping 6) — самые слабо задемпфированные. Корона (12) и каска (15) разгон гасили и накопить ему не давали — поэтому на них никто и не жаловался. Баг был буквально пропорционален мягкости шляпы.

Дальше арифметика. Накопленной скорости хватало перелететь цель не на пару градусов, а на 200–360. А для rotate перелёт на 360° — это ровно полный оборот. «Вжух», инерция сгорела, шляпа села на место. «Покрутилась и остановилась» — это не глюк рендера, это недодемпфированная пружина честно отрабатывает скорость, которую я в неё сам накачал.

Фикс — это минус код

Убрал rAF-цикл целиком: Framer и так умеет пружину, второй, ручной, не нужен. И перестал менять угол покадрово — теперь Framer получает дискретную цель, которая стоит на месте по полсекунды. Вот как это лежит сейчас:

const [targetRotation, setTargetRotation] = useState(0);

useEffect(() => {
  if (!isSpeaking) {
    setTargetRotation(0);
    return;
  }
  // при разговоре — плавные случайные наклоны головы ±3°
  const interval = setInterval(() => {
    setTargetRotation((Math.random() - 0.5) * 6);
  }, 400 + Math.random() * 300); // каждые 400–700 мс новая цель
  return () => clearInterval(interval);
}, [isSpeaking, hatType]);

<motion.div
  animate={{ rotate: targetRotation }}
  transition={{ type: 'spring', stiffness: position.springStiffness, damping: position.springDamping }}
/>

Пружина осталась одна, цель у неё полсекунды не двигается — доехать успевает спокойно, копить скорость неоткуда. Плюс угол зажат в ±3°, так что оборот стал невозможен физически: перелетит на градус-два, а не на круг. Покачивание при этом не поменялось — damping у мягких шляп те же 6–7, просто теперь они не суммируются с ручным циклом.

Строк стало меньше, шляпа перестала улетать.

Вывод себе записал короткий: не отдавать пружине Framer значение, которое дёргаешь на каждом кадре — она копит скорость и однажды выстрелит ей в лицо. Если очень надо анимировать непрерывно меняющуюся величину, это уже motion values или свой контроль трансформа, где скоростью управляешь ты, а не Framer втихую. А две анимации на одном rotate были плохой идеей с самого начала — просто ковбойская шляпа наконец показала, насколько.