На страницах со списком вопросов для собеседований в продукте FrontPrep рядом с вопросами показываются логотипы компаний. При наведении на логотип появляется тултип с названием компании.

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

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

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

Решение

Решение делится на три части.

  1. Курсор наводится на логотип. Тултип ждёт 200мс перед открытием.
  2. Тултип закрывается, и запускается таймер на 300мс — назовём это «тёплым окном» или «тёплой страницей». Если навести курсор на другой логотип, пока этот 300мс таймер работает (то есть пока страница «тёплая»), тултип открывается мгновенно, без ожидания и анимации.
  3. Когда таймер 300мс истекает, всё возвращается в норму, и страница становится «холодной». При повторном наведении на логотип следующему тултипу снова придётся ждать 200мс. Без этого шага первый открытый тултип отключил бы задержку для всей страницы навсегда.
hover → wait 200ms → tooltip opens (page is now warm)
leave → tooltip closes → 300ms cooldown
            ├─ hover another tooltip before cooldown → opens instantly, page stays warm
            └─ cooldown ends → page is cold, the 200ms wait is back

В FrontPrep тултипы построены на Radix и Motion. Ниже — демонстрация паттерна на упрощённой версии React, а позже этот же подход оформлен в виде Claude-скилла, который можно использовать для аудита собственной кодовой базы.

В отдельной статье тот же UX воспроизведён без единой строчки JavaScript.

Как работает код

Код разобран в том же порядке, в котором происходят события при движении курсора.

Шаг 1: наведение на логотип

Когда на логотипе срабатывает onMouseEnter, вызывается эта функция:

function handleEnter() {
  // `tooltips` is a useContext variable where the isWarm state is tracked between components
  If (tooltips.isWarm) {
    show();
    return;
  }
  // `openTimer` is a useRef variable, so .current is how you set/access the value.
  openTimer.current = setTimeout(show, tooltips.openDelay);
}

При наведении компонент задаёт один вопрос: тёплая ли страница? Если да — тултип открывается мгновенно. Если нет — запускается таймер на 200мс. Объект tooltips приходит из TooltipProvider, который рассматривается на последнем шаге.

Шаг 2: тултип открывается

Функция show, отвечающая за открытие тултипа:

function show() {
  setInstant(tooltips.isWarm);
  setOpen(true);
  tooltips.markOpened();
}

Она делает три вещи:

  1. Копирует значение tooltips.isWarm в состояние instant.
  2. Открывает тултип.
  3. Сообщает провайдеру, что тултип открыт, — это делает страницу «тёплой».

Флаг instant добавляется к тултипу как data-атрибут, который CSS использует для пропуска анимации появления.

.tooltip[data-instant="true"] {
  transition-duration: 0ms;
}

Именно поэтому на «тёплой» странице тултип открывается без анимации и задержки.

Шаг 3: курсор покидает логотип

Когда на логотипе срабатывает onMouseLeave, вызывается эта функция:

function handleLeave() {
  clearTimeout(openTimer.current);
  If (!open) return;
  setOpen(false);
  setInstant(false);
  tooltips.markClosed();
}

Сначала нужно узнать, открыт ли тултип в данный момент.

Допустим, курсор пересекает логотип за 50мс — это намного меньше 200мс, поэтому таймер, запущенный handleEnter, всё ещё работает, а тултип ещё не открылся. clearTimeout внутри handleLeave отменяет этот таймер, так что тултип вообще не открывается, а строка if (!open) return; сразу завершает функцию, потому что тултипу, который так и не открылся, нечего закрывать и незачем запускать остывание. В первом видео взмах курсора открывал каждый тултип на своём пути; теперь тот же взмах не открывает ни одного.

Если тултип открыт — это происходит, когда курсор задерживается на логотипе дольше 200мс, либо когда страница «тёплая» и тултип открывается мгновенно при наведении, — тултип закрывается, провайдер получает уведомление и запускает 300мс таймер остывания. Если навести курсор на следующий логотип до истечения этого таймера, тултип откроется мгновенно.

Шаг 4: провайдер

const warm = useRef(false);
const cooldownTimer = useRef(null);

useEffect(() => () => clearTimeout(cooldownTimer.current), []);

const tooltips = useMemo(
  () => ({
    openDelay,
    isWarm: () => skipWhenWarm && warm.current,
    markOpened() {
      warm.current = true;
      clearTimeout(cooldownTimer.current);
    },
    markClosed() {
      clearTimeout(cooldownTimer.current);
      cooldownTimer.current = setTimeout(() => {
        warm.current = false;
      }, warmFor);
    },
  }),
  [openDelay, warmFor, skipWhenWarm]
);

Это общее состояние, необходимое для работы всего паттерна. Страница может быть либо «тёплой», либо «холодной». Метод markOpened отменяет ожидающие таймеры остывания — именно это удерживает страницу «тёплой» при переходе от одного логотипа к другому. По сути, каждый новый тултип отменяет остывание, запущенное предыдущим.

Флаг skipWhenWarm нужен только для переключателя «до/после» в демо; при его отключении провайдер всегда остаётся «холодным», чтобы показать исходное поведение.

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

Почему именно 200мс?

При таймере меньше 150мс курсор, просто проходящий над триггером тултипа, скорее всего, всё равно откроет подсказку. При таймере больше 250мс наведение будет ощущаться медленным и сломанным. Поэтому 200мс — сбалансированное значение для этого случая.

Суждение и вкус

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

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

Скилл

Для решения описанной проблемы создан Claude-скилл — можно построить аналогичный или скопировать написанный ниже и запустить его для аудита собственной кодовой базы. Каждая кодовая база отличается: где-то используются разные библиотеки для тултипов, где-то написаны кастомные реализации — этот скилл подойдёт для любого варианта.

.claude
  /skills
    /tooltip
      /SKILL.md
---
name: tooltip
description: Tooltips need a delay so they don't open up on unintentional mouse travel
---

# Tooltip Timing

A click is always intentional whereas a hover is not intentional. The cursor travels across the page to get wherever it has to, and it passes over elements on the way, so a tooltip cannot tell from the hover whether the user wants to open it. The 200ms is how it finds out.

```css
.tooltip {
 transition-delay: 200ms;
}
```

## The three numbers and states

| Value       | Number | Why                                                                                                     |
| ----------- | ------ | ------------------------------------------------------------------- |
| Open delay  | 200ms  | Below 150ms a cursor that is only passing over a trigger still opens it. Above 250ms an intentional hover feels broken. |
| Warm window | 300ms  | Long enough to cover the move from one trigger to the next one. Short enough that a hover a second later waits again. |
| Close delay | 0ms    | Leaving a trigger should be clear. So there is nothing to wait for.|

## How it should behave

```
hover -> wait 200ms -> tooltip opens (page is now warm)
leave -> tooltip closes -> 300ms cooldown
```