Mert Özen Her Satırda Daha İleri
Frontend JavaScript İleri — #17

JavaScript'te Animasyon ve Scroll API'leri: element.animate, getAnimations, finished Promise'i, scrollIntoView ve scrollend

Mert Özen 15 Kas 2026 12 dk 6 görüntülenme
JavaScript'te Animasyon ve Scroll API'leri: element.animate, getAnimations, finished Promise'i, scrollIntoView ve scrollend

JavaScript'te animasyonu element.animate ile başlatır, dönen Animation object'iyle yönetir, bitişini finished promise'iyle beklersin; kaydırmada scrollIntoView ve scrollBy ile hedefe gider, işi scrollend ile kaydırma bitince yaparsın. Bookstore'da sepete uçan kapak, checkout'ta hatalı alana gitme ve kitap şeridi oklarıyla, gerçek tarayıcı çıktılarıyla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar ES2026 (ECMAScript 2026) ve güncel tarayıcılar (Chrome, Edge, Firefox, Safari) içindir. Örnekler Playwright 1.65'in Chromium 158 ve WebKit motorlarında, 127.0.0.1'de sunulan bir Bookstore sayfasında denenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) ana sayfasında bir kampanya bandı var. Onu JavaScript ile 200 piksel sağa kaydırıyoruz. Animasyon bitince band nerede durur, konsola ne yazar?

const banner = document.querySelector(".sale-banner");
const slide = banner.animate(
  [{ transform: "translateX(0)" }, { transform: "translateX(200px)" }],
  { duration: 500 }
);

console.log(slide.playState);
slide.finished.then(() => {
  console.log(slide.playState, getComputedStyle(banner).transform);
});

Önce running, yarım saniye sonra finished none yazar. Band 200 piksel gider ve animasyon biter bitmez başladığı yere geri zıplar. Bu bir hata değil: element.animate element'in stiline dokunmaz, yalnız animasyon sürerken bir katman ekler. Animasyon bitince katman kalkar ve element'in kendi stili geri gelir. Bu yazıda bu davranışı nasıl yöneteceğini, animasyonun bittiğini nasıl anlayacağını ve sayfayı JavaScript ile nasıl kaydıracağını göreceğiz.

element.animate ve Animation Object'i

element.animate(keyframes, options), Web Animations API'nin kapısı. CSS3 serisinin transition ve animation yazısındaki @keyframes'in JavaScript'teki karşılığı gibi düşünebilirsin; fark, değerleri çalışma anında hesaplayabilmen ve animasyonu bir object olarak elinde tutman. Web-features explorer'a göre bu API Eylül 2020'den beri bütün ana tarayıcılarda var ve Mart 2023'ten beri widely available.

const duneCover = document.querySelector("#book-dune .cover");

const wiggle = duneCover.animate(
  [
    { transform: "rotate(0deg)" },
    { transform: "rotate(-6deg)", offset: 0.25 },
    { transform: "rotate(6deg)", offset: 0.75 },
    { transform: "rotate(0deg)" }
  ],
  { duration: 400, iterations: 3, easing: "ease-in-out", delay: 100 }
);

console.log(wiggle.playState); // running

Keyframe'ler bir array'dir; offset vermezsen eşit aralıklarla dağılır. Tek property'li kısa animasyonlarda { opacity: [0, 1] } gibi bir object de yazabilirsin, ikinci argüman yalnız bir sayıysa süre olarak alınır. Yukarıdaki animasyon toplam 1300 ms sürer: 100 ms gecikme ve 400 ms'lik üç tekrar.

animate'in döndürdüğü Animation object'i bir oynatıcı gibi çalışır. Durdurabilir, belirli bir ana sarabilir, hızlandırabilir ya da tamamen iptal edebilirsin:

const emmaCover = document.querySelector("#book-emma .cover");
const spin = emmaCover.animate(
  { transform: ["rotate(0deg)", "rotate(360deg)"] },
  { duration: 1000, iterations: Infinity }
);

spin.pause();
spin.currentTime = 250;
console.log(spin.playState, getComputedStyle(emmaCover).transform); // paused matrix(0, 1, -1, 0, 0, 0)

spin.playbackRate = 2;
spin.play();
console.log(spin.playState); // running

spin.reverse();
spin.cancel();
console.log(spin.playState, getComputedStyle(emmaCover).transform); // idle none

currentTime = 250, bir saniyelik turun dörtte biri, yani 90 derece; matrix(0, 1, -1, 0, 0, 0) tam olarak 90 derecelik dönüş. reverse yönü çevirir, cancel ise animasyonu ve etkisini tamamen kaldırır.

prefers-reduced-motion'ı JavaScript'te Okumak

Bazı kullanıcılar işletim sisteminde "hareketi azalt" ayarını açar; hareket onları rahatsız eder ya da baş dönmesi yapar. CSS'te bunu @media (prefers-reduced-motion: reduce) ile karşılıyorsun. JavaScript ile başlattığın animasyonlar bu media query'den etkilenmez; tercihi kendin okuman gerekir:

const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
const motionOk = () => !reducedMotion.matches;

console.log(reducedMotion.matches); // false
reducedMotion.addEventListener("change", () => {
  console.log("reduce motion:", reducedMotion.matches);
});

matches'i bir kez okuyup bir değişkene kopyalama; kullanıcı ayarı sayfa açıkken değiştirebilir. Playwright'ta sayfa açıkken bu ayarı değiştirdiğimde change event'i geldi ve konsola reduce motion: true yazdı. Bundan sonraki örneklerde her animasyondan önce motionOk()'a bakacağız.

finished Promise'i: "Sepete Ekle" Animasyonu

Müşteri "Add to cart" butonuna bastığında kitabın kapağı küçülerek sepet ikonuna uçsun. Kapağın ve ikonun konumunu Orta serideki gelişmiş DOM yazısında gördüğümüz getBoundingClientRect ile ölçüyoruz, kapağın bir kopyasını position: fixed ile aynı yere koyuyoruz ve kopyayı canlandırıyoruz:

async function flyToCart(button) {
  if (!motionOk()) return;
  const cover = button.closest(".book").querySelector(".cover");
  const from = cover.getBoundingClientRect();
  const to = document.querySelector("#cart-icon").getBoundingClientRect();

  const ghost = cover.cloneNode(true);
  Object.assign(ghost.style, {
    position: "fixed", left: `${from.left}px`, top: `${from.top}px`,
    width: `${from.width}px`, height: `${from.height}px`, margin: "0", pointerEvents: "none"
  });
  document.body.append(ghost);

  const dx = to.left + to.width / 2 - (from.left + from.width / 2);
  const dy = to.top + to.height / 2 - (from.top + from.height / 2);
  const flight = ghost.animate(
    [
      { transform: "translate(0, 0) scale(1)", opacity: 1 },
      { transform: `translate(${dx}px, ${dy}px) scale(0.1)`, opacity: 0.5 }
    ],
    { duration: 600, easing: "ease-in" }
  );

  try {
    await flight.finished;
  } catch (err) {
    if (err.name !== "AbortError") throw err; // cancel() edilen animasyon AbortError ile reject olur
  } finally {
    ghost.remove();
  }
}

finished, animasyon bittiğinde resolve olan bir promise. Kopyayı ancak o zaman kaldırıyoruz; remove'u animate'in hemen arkasına yazsaydık kullanıcı hiçbir şey görmezdi. Animasyon cancel ile iptal edilirse promise AbortError ile reject olur. Bunu yakalamazsan sayfada yakalanmamış bir promise hatası oluşur; Chromium'da "The user aborted a request.", WebKit'te "Unhandled Promise Rejection: AbortError" mesajını gördüm. finally her iki durumda da kopyayı temizler. Hareket azaltılmışsa fonksiyon hiç animasyon yapmadan döner.

Buton tarafında önemli bir karar var: sepete ekleme işini animasyona bağlama. Kitap sepete hemen girer; animasyon yalnız görsel bir geri bildirim:

const cart = [];
const cartCount = document.querySelector("#cart-count");

document.querySelector(".shelf").addEventListener("click", async (event) => {
  const button = event.target.closest(".add-to-cart");
  if (!button) return;

  cart.push(button.closest(".book").querySelector(".cover").textContent); // önce durum, animasyondan bağımsız
  await flyToCart(button);
  cartCount.textContent = cart.length;
  if (motionOk()) cartCount.animate([{ transform: "scale(1.5)" }, { transform: "scale(1)" }], 200);
  console.log("in cart:", cart.length, "ghosts left:", document.querySelectorAll("body > .cover").length);
});

Dune'un butonuna tıkladığımda iki motorda da 600 ms sonra konsola in cart: 1 ghosts left: 0 düştü; uçuşun ortasında, 300. milisaniyede, sayfada tek bir kopya vardı. Ardından Emma'nın butonuna hızlıca iki kez bastım: iki kopya aynı anda uçtu, ilki inince in cart: 3 ghosts left: 1, ikincisi inince in cart: 3 ghosts left: 0 yazdı. Sepet sayısı ikisinde de 3, çünkü durum tıklama anında güncellendi. Hareket azaltmayı açıp The Hobbit'e tıkladığımda hiç kopya oluşmadan rozet anında 4 oldu.

fill ve commitStyles

Baştaki bandın geri zıplamasını önlemenin ilk yolu fill: "forwards": animasyon bitince son keyframe uygulanmaya devam eder. Ama bunun bir bedeli var, kampanya bandını gizleyip sonra yeniden göstermeye çalışalım:

const hide = banner.animate({ opacity: [1, 0] }, { duration: 300, fill: "forwards" });
await hide.finished;
console.log(getComputedStyle(banner).opacity); // 0

banner.style.opacity = "1";
console.log(getComputedStyle(banner).opacity); // 0: fill hâlâ kazanıyor

hide.commitStyles();
hide.cancel();
console.log(banner.style.opacity); // 0: değer artık style attribute'unda
banner.style.opacity = "1";
console.log(getComputedStyle(banner).opacity); // 1

Doldurmaya devam eden bir animasyon, element'in kendi stilinin önüne geçer; style.opacity'yi değiştirmen işe yaramaz. commitStyles, animasyonun o anki değerini element'in style attribute'una yazar; cancel ile animasyonu kaldırınca element yine normal stillenir. MDN, yeni tarayıcılarda fill vermeden de commitStyles'ın çalıştığını ama bunun feature detection'ı olmadığı için fill'i yazmaya devam etmeyi öneriyor.

Aynı element'te aynı property'yi doldurmaya devam eden animasyonlar birikirse tarayıcı eskileri kendisi kaldırır:

const hobbitCover = document.querySelector("#book-hobbit .cover");
const steps = [0, 1, 2].map((i) =>
  hobbitCover.animate(
    { transform: [`translateX(${i * 10}px)`, `translateX(${i * 10 + 10}px)`] },
    { duration: 50, fill: "forwards" }
  )
);

await steps[2].finished;
console.log(steps.map((a) => a.replaceState), hobbitCover.getAnimations().length);
// ["removed", "removed", "active"] 1
steps[2].cancel();

Altta kalan iki animasyon removed oldu. Bu sayede her tıklamada yeni bir fill animasyonu ekleyen kod bellekte binlerce animasyon biriktirmez. Birini tutmak istersen persist() çağırırsın.

getAnimations ile CSS Animasyonlarını Yönetmek

getAnimations yalnız JavaScript ile başlattıklarını değil, CSS ile tanımlanmış animasyon ve transition'ları da Animation object'i olarak verir. Sayfadaki "Sale" etiketi CSS'teki @keyframes pulse ile sürekli büyüyüp küçülüyor:

const running = document.getAnimations();
console.log(running.map((a) => `${a.constructor.name} ${a.animationName}`)); // ["CSSAnimation pulse"]

const header = document.querySelector(".site-header");
console.log(header.getAnimations().length, header.getAnimations({ subtree: true }).length); // 0 1

running.forEach((a) => a.pause());

Etiket header'ın içinde olduğu için header'ın kendi listesi boş; subtree: true alt element'leri de katar. Bir kapağın üzerine geldiğinde başlayan CSS transition da listede CSSTransition olarak, transitionProperty değeri transform ile görünür. Bunu iki motorda fareyi kapağın üzerine getirerek gördüm. Bir modal açılırken arkadaki bütün hareketi durdurmak, testte animasyonları bitirmek ya da bir CSS animasyonunun finished'ını beklemek için bu listeyi kullanırsın. Sonsuz tekrar eden bir animasyonda finish() InvalidStateError fırlatır; onları pause ya da cancel ile durdur.

scrollIntoView ile Hatalı Alana Gitmek

Checkout formunu gönderirken bir alan eksikse müşteriyi o alana götürmek istiyoruz. Form'da novalidate var, yani tarayıcının kendi uyarı balonunu kapattık ve işi kendimiz yapıyoruz:

const checkoutForm = document.querySelector("#checkout");

function focusFirstError(form) {
  const field = form.querySelector(":invalid");
  if (!field) return null;
  field.scrollIntoView({ block: "center", behavior: motionOk() ? "smooth" : "instant" });
  field.focus({ preventScroll: true });
  return field;
}

checkoutForm.addEventListener("submit", (event) => {
  if (checkoutForm.checkValidity()) return;
  event.preventDefault();
  const field = focusFirstError(checkoutForm);
  console.log("first error:", field.id, "focused:", document.activeElement === field);
});

block dikey hizalamayı seçer: "start" (varsayılan), "center", "end" ve "nearest". "nearest" element zaten görünüyorsa hiç kaydırmaz. inline aynı işi yatayda yapar, varsayılanı "nearest". focus'a preventScroll: true verdik; vermeseydik focus sayfayı anında alana atlatır ve yumuşak kaydırmayı bozardı. Adı doldurup e-postayı boş bıraktığımda konsola first error: email focused: true düştü Kaydırma bittiğinde alanın üst kenarı 376. pikseldeydi: 700 piksellik görünür alanda ortalanan şey alanın kendisi değil, alan ile üstündeki 72 piksellik scroll-margin birlikte. Bu pay sayfanın CSS'inde tanımlı; hemen aşağıda ona bakıyoruz.

Sayfanın üstünde sabit (sticky) bir header varsa block: "start" alanı onun altına saklar. Çözüm CSS'te: scroll-margin-top, CSS Property'leri serisinin overflow ve scroll davranışı yazısında anlatıldığı gibi, hedefin hizalanırken bıraktığı payı belirler:

#checkout :is(input, textarea) { scroll-margin-top: 72px; } /* 56 px header + 16 px boşluk */
const postalCode = document.querySelector("#postal-code");
postalCode.scrollIntoView({ block: "start", behavior: "instant" });
console.log(Math.round(postalCode.getBoundingClientRect().top)); // 72

postalCode.style.scrollMarginTop = "0px";
postalCode.scrollIntoView({ block: "start", behavior: "instant" });
console.log(Math.round(postalCode.getBoundingClientRect().top)); // 0: header'ın altında kaldı

Kaydırmanın diğer iki aracı scrollTo ve scrollBy: ilki mutlak bir konuma, ikincisi bulunduğun yerden göreli kaydırır. "Başa dön" butonu için window.scrollTo({ top: 0, behavior: "smooth" }) yeter. behavior: "auto" ise CSS'teki scroll-behavior değerine uyar.

scrollend ile Kaydırma Bitince İş Yapmak

Ana sayfadaki "Bestsellers" şeridi yatay kayan bir liste; iki yanında ok butonları var. Şerit başa dayanınca sol ok, sona dayanınca sağ ok kapanmalı. Bunu her scroll event'inde değil, kaydırma bittiğinde bir kez yapmak istiyoruz:

const shelf = document.querySelector(".shelf");
const prevButton = document.querySelector("#shelf-prev");
const nextButton = document.querySelector("#shelf-next");

function updateArrows() {
  const maxScroll = shelf.scrollWidth - shelf.clientWidth;
  prevButton.disabled = shelf.scrollLeft <= 0;
  nextButton.disabled = shelf.scrollLeft >= maxScroll - 1;
  console.log("scrollLeft:", shelf.scrollLeft, "prev:", !prevButton.disabled, "next:", !nextButton.disabled);
}

function scrollShelf(direction) {
  shelf.scrollBy({ left: direction * shelf.clientWidth, behavior: motionOk() ? "smooth" : "instant" });
}

prevButton.addEventListener("click", () => scrollShelf(-1));
nextButton.addEventListener("click", () => scrollShelf(1));
shelf.addEventListener("scrollend", updateArrows);
updateArrows(); // scrollLeft: 0 prev: false next: true

Sağ oka bastığımda konsola tek satır düştü: scrollLeft: 560 prev: true next: false. Şerit 600 piksel genişliğinde, içerik 1160 piksel; 600 piksel kaydırmak istedik, tarayıcı en fazla 560'a kadar gidebildi. Aynı yumuşak kaydırma sırasında Chromium 23, WebKit koşudan koşuya 14 ya da 15 scroll event'i üretti; scrollend ise ikisinde de bir kez geldi. Konum hiç değişmezse scrollend gelmez; sola dayalı şeritte sol oka basınca hiçbir şey yazmadı. MDN'ye göre dokunmatik ekranda parmak ekrandayken de gelmez, kullanıcı bırakınca gelir.

Web-features explorer'a göre scrollend Aralık 2025'te Safari 26.2 ile Baseline oldu; Chrome 114'ten, Firefox 109'dan beri destekliyor. Daha eski Safari'leri de destekliyorsan event'i kontrol edip performans yazısındaki debounce'a düşebilirsin:

if (!("onscrollend" in window)) {
  let scrollTimer;
  shelf.addEventListener("scroll", () => {
    clearTimeout(scrollTimer);
    scrollTimer = setTimeout(updateArrows, 150);
  }, { passive: true });
}

Debounce son scroll event'inden 150 ms sonra çalışır; yavaş bir kaydırmanın ortasında da tetiklenebilir ve her zaman biraz gecikir. scrollend ise tarayıcının "kaydırma bitti" kararıdır. Bir not daha: Chromium 158'de scrollIntoView ve scrollBy artık kaydırma bitince resolve olan bir promise döndürüyor (sonuç { interrupted: false }); WebKit'te ise aynı çağrı undefined döndürdü. Bu henüz Baseline değil; bugün kaydırmanın bitişi için scrollend'e güven.

Üretimde Dikkat

Ne canlandırdığın, nasıl canlandırdığından daha önemli. Performans yazısında layout thrashing'i görmüştük; width, height, top, left gibi property'leri canlandırmak her karede layout'u yeniden hesaplatır. Aynı kapağı bir saniye boyunca önce width ile, sonra transform ile küçülttüm:

const middlemarchCover = document.querySelector("#book-middlemarch .cover");
await middlemarchCover.animate({ width: ["180px", "120px"] }, 1000).finished;
await middlemarchCover.animate({ transform: ["scale(1)", "scale(0.66)"] }, 1000).finished;

Chromium'un DevTools Protocol'ündeki performans sayaçlarına göre width animasyonu sırasında layout sayısı 62 arttı, yani her karede bir; transform animasyonu sırasında yalnız 2. transform ve opacity layout'a dokunmaz ve tarayıcı onları ana thread'den bağımsız çizebilir. Uçan kapakta left/top yerine transform: translate kullanmamızın nedeni bu. Konumu ölçmek için getBoundingClientRect'i animasyondan önce bir kez çağırdık; döngüde ölçüp yazmak yine thrashing'e döner. Tarayıcının bir kareyi nasıl çizdiğini event loop yazısındaki rendering adımında görmüştük.

CSS mi JavaScript mi? Hover, açılır menü ve sayfa geçişleri gibi durumu belli animasyonlar CSS'te kalsın; CSS3 serisinin transition ve animation yazısı ile scroll-driven animation yazısı bunları anlatıyor. JavaScript'i değerler çalışma anında hesaplandığında (uçan kapağın hedefi), animasyonlar sıralı çalışması gerektiğinde (await ile biri bitince öteki), iptal edilebilmeleri ya da bitişlerinin beklenmesi gerektiğinde seç. requestAnimationFrame ile her kareyi elle çizmeye ise ancak her karede hesaplanan bir şey (fizik, canvas) varsa ihtiyaç duyarsın.

scroll event'ine bağlandıysan listener'ı { passive: true } ile ekle ve içinde ağır iş yapma; bir element'in görünür olup olmadığını öğrenmek için Observer API'leri yazısındaki IntersectionObserver daha doğru araç.

Sık Düşülen Tuzaklar

Animasyon bitmeden element'i DOM'dan kaldırmak. Aşağıdaki kodda animasyon arka planda tamamlanır ama kullanıcı solmayı hiç görmez:

const notice = document.querySelector(".sale-banner");
const fadeOut = notice.animate({ opacity: [1, 0] }, 300);
notice.remove(); // yanlış: önce await fadeOut.finished, sonra remove
await fadeOut.finished;
console.log(fadeOut.playState, notice.isConnected); // finished false

İki motorda da promise yaklaşık 300 ms sonra finished ile resolve oldu; yani kod hata vermez, yalnız ekranda bir şey olmaz. Doğrusu remove'u await'ten sonraya almak.

Layout tetikleyen property'leri canlandırmak. width, top, margin yerine transform ve opacity kullan.

Hareket azaltma tercihini yok saymak. CSS'teki @media kuralı JavaScript animasyonlarını durdurmaz; motionOk() gibi bir kontrolü her animate ve behavior: "smooth" öncesine koy.

scroll event'inde ağır iş yapmak. Tek bir yumuşak kaydırma onlarca event üretir. Bitişe bağlı işler için scrollend, görünürlük için IntersectionObserver kullan.

Sonsuz bir animasyonun finished'ını beklemek. iterations: Infinity olan bir animasyonun promise'i hiç resolve olmaz; await eden kod sonsuza kadar bekler.

Küçük Bir Deneme

★☆☆ Kampanya bandını sayfa açılınca yukarıdan kaydırarak getir (translateY(-100%) → translateY(0)), animasyon bitince geri zıplamasın. Bunu bir kez fill: "forwards" ile, bir kez de commitStyles ile yap ve getAnimations().length değerlerini karşılaştır.

★★☆ Sepetten kitap çıkarınca satırın önce solup sonra yüksekliğinin kapanarak kaybolmasını sağla: iki animasyonu await ile sırayla çalıştır, ikisi bitince satırı DOM'dan kaldır. Hareket azaltma açıkken satır animasyonsuz kaldırılsın.

★★★ "Bestsellers" şeridine klavye desteği ekle: şerit odaktayken sağ/sol ok tuşları bir kitap kartı kadar kaydırsın, kaydırma bitince (scrollend) görünür ilk kartın "Add to cart" butonuna preventScroll: true ile focus versin. scrollend'i desteklemeyen tarayıcı için debounce yedeğini de ekle ve iki yolda updateArrows'un kaç kez çağrıldığını say.

İleri seri burada bitiyor. Prototype zincirinden event loop'a, bellek yönetiminden Web Worker'lara, Web Components'tan Temporal'a ve son olarak animasyon ve kaydırma API'lerine kadar tarayıcının ve dilin iç işleyişini gördün. JavaScript her yıl yeni bir sürümle büyüyor; karşına yeni bir özellik çıktığında önce TC39 aşamasına, sonra Baseline durumuna bakmayı alışkanlık hâline getir.