Como acionar efeitos após a conclusão da rolagem programática

Ao rolar de forma programática para uma seção ou elemento (especialmente com behavior: 'smooth'), acionar uma ação de acompanhamento imediatamente, como piscar um estado de destaque, iniciar uma animação de entrada ou mover o foco, faz com que o efeito seja executado enquanto o destino ainda está fora da tela ou no meio da rolagem.

Antes, os desenvolvedores precisavam adivinhar a duração da rolagem com setTimeout() (que é acionado muito cedo em rolagens longas e muito tarde em rolagens curtas) ou encapsular manualmente um listener de eventos scrollend em um Promise com um timer de fallback (porque scrollend não é acionado quando o destino já está visível e não há rolagem).

Os métodos de rolagem programática em Element (scrollIntoView(), scroll(), scrollTo(), scrollBy()) e Window (scroll(), scrollTo(), scrollBy()) retornam um Promise que é resolvido quando a operação de rolagem é concluída. Se o destino já estiver na posição de rolagem solicitada e nenhuma rolagem for necessária, o Promise retornado será resolvido imediatamente sem travamentos.

Implementação

  1. Marque o gerenciador como async: use uma função async para o listener de eventos ou o gerenciador de navegação que aciona a rolagem.
  2. Aguarde o método de rolagem diretamente: chame await element.scrollIntoView({ behavior: 'smooth', ... }) (ou await element.scrollTo(...), await window.scrollTo(...) etc.). A execução é pausada até que o navegador termine a animação de rolagem e a posição de rolagem seja definida.
  3. Aplicar o efeito pós-rolagem: alterne a classe de destaque, inicie a animação ou mova o foco depois que a expressão await for concluída.

Rolagens interrompidas e gerenciamento de foco

  • Como lidar com rolagens rápidas / interrompidas: se uma nova rolagem começar no mesmo contêiner antes que uma rolagem suave em andamento termine, o navegador vai cancelar a rolagem suave em andamento e resolver (não rejeitar) todas as promessas de rolagem pendentes nesse contêiner. Se você precisar suprimir o efeito pós-rolagem para rolagens substituídas quando um usuário clicar rapidamente, rastreie um ID de operação de incremento e retorne logo após await se uma nova rolagem tiver sido iniciada:
let currentScrollId = 0;
const sections = document.querySelectorAll('section');

async function scrollToAndHighlight(targetElement) {
  const scrollId = ++currentScrollId;
  sections.forEach((sec) => sec.classList.remove('highlight'));

  await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });

  // Abort follow-up effect if another scroll was triggered while awaiting
  if (scrollId !== currentScrollId) return;

  targetElement.classList.add('highlight');
}
  • Focar o destino após a rolagem: ao mover o foco do teclado para o elemento de destino (ou um título dentro dele com tabindex="-1") após await targetElement.scrollIntoView(...), transmita { preventScroll: true } para focus() para que a chamada de foco não inicie uma segunda rolagem concorrente:
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetHeading.focus({ preventScroll: true });

Restrições e práticas recomendadas

  • OBRIGATÓRIO: aguarde o Promise retornado diretamente por scrollIntoView(), scroll(), scrollTo() ou scrollBy() em vez de adivinhar as durações de rolagem com setTimeout().
  • NÃO envolva scrollend em um new Promise() manual como sua implementação principal. O scrollend não é acionado quando o destino já está visível e nenhuma distância de rolagem é percorrida, enquanto as promessas do método de rolagem são resolvidas automaticamente nesse caso.
  • RECOMENDADO: transmita { preventScroll: true } ao chamar .focus() depois de aguardar um método de rolagem para que o navegador não pule ou role uma segunda vez.
  • RECOMENDAÇÃO: proteja os efeitos pós-rolagem com um token de solicitação se interações rápidas do usuário puderem acionar rolagens suaves sobrepostas no mesmo contêiner, já que interromper uma rolagem suave resolve (em vez de rejeitar) a promessa anterior.
  • Bug conhecido do Chrome com o rolador raiz: o Chrome tem um bug em que a Promise retornada por scrollIntoView() é resolvida muito cedo quando o rolador raiz do documento está rolando. Aguardar um scrollIntoView() em um contêiner de rolagem aninhado funciona conforme o esperado. Não envolva o conteúdo em um contêiner de rolagem, como div com overflow-y: auto, como uma solução alternativa para esse bug.

Estratégias de fallback

Em navegadores que não são compatíveis com promessas de método de rolagem, scrollIntoView(), scroll(), scrollTo() e scrollBy() retornam undefined em vez de Promise.

  • Aprimoramento progressivo (comportamento padrão de await): como await undefined é um JavaScript válido que é resolvido imediatamente na próxima microtarefa, escrever await element.scrollIntoView({ behavior: 'smooth' }) não vai gerar um erro em navegadores sem suporte. Em vez disso, ele se degrada de forma otimizada executando o código pós-rolagem assim que a rolagem começa. Para detalhes visuais não críticos, como um destaque temporário, isso geralmente é suficiente.
  • Fallback fiel (scrollend + tempo limite): se o destino do Baseline não for compatível com scroll-promises e a ação pós-rolagem precisar esperar até que a rolagem termine em navegadores mais antigos, verifique se o valor de retorno é um Promise (result instanceof Promise). Quando for undefined, aguarde o evento scrollend no contêiner de rolagem com um tempo limite de fallback curto, caso o elemento já esteja visível e nenhuma rolagem ocorra:
function scrollIntoViewAsync(element, options, scroller = window) {
  const result = element.scrollIntoView(options);

  // Feature detect: in supporting browsers, scrollIntoView() returns a Promise
  if (result instanceof Promise) {
    return result;
  }

  // Fallback for browsers where scrollIntoView() returns undefined
  return new Promise((resolve) => {
    let timeoutId;

    const finish = () => {
      clearTimeout(timeoutId);
      scroller.removeEventListener('scrollend', finish);
      scroller.removeEventListener('scroll', onScroll);
      resolve();
    };

    const onScroll = () => {
      // Restart the idle timer on every scroll event. If scrollend never fires
      // (browsers without scrollend support), this resolves once scrolling goes idle.
      clearTimeout(timeoutId);
      timeoutId = setTimeout(finish, 100);
    };

    scroller.addEventListener('scrollend', finish, { once: true });
    scroller.addEventListener('scroll', onScroll, { passive: true });

    // If no scroll event fires within the idle window (e.g., already in view), resolve.
    // 100ms is an example value; it only needs to exceed the gap between scroll events.
    timeoutId = setTimeout(finish, 100);
  });
}