Mert Özen Her Satırda Daha İleri
Frontend Angular — #35

Angular'da Animasyon: animate.enter, animate.leave, CSS ile Geçişler ve @angular/animations'tan Geçiş

Mert Özen 15 Kas 2026 8 dk 5 görüntülenme
Angular'da Animasyon: animate.enter, animate.leave, CSS ile Geçişler ve @angular/animations'tan Geçiş

Angular 20.2 ile gelen animate.enter ve animate.leave, element DOM'a girerken ve çıkarken bir CSS class'ı ekleyip animasyonun bitmesini bekler; @angular/animations gerekmez. Bookstore sepetinde satır ve toast animasyonları, prefers-reduced-motion, @starting-style, withViewTransitions ve eski trigger kodundan geçişi anlatıyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2, TypeScript 6.0, Vitest 4.1 ve Playwright 1.65 (Chromium) ile denenmiştir.

Önce Tahmin Et

Sepet sayfasında iki satır var: Dune ve Emma. Satırlar @for ile listeleniyor ve track $index kullanılıyor; her satırın bir çıkış animasyonu var. İlk satırdaki (Dune) "Remove" butonuna basıyorsun. Ekranda hangi satır kayarak kaybolur?

Cevabı "Sık Düşülen Tuzaklar"da, Chromium'da kaydettiğimiz gerçek davranışla veriyoruz. Önce Angular'ın yeni animasyon API'sine bakalım. Örnek, sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Angular istemcisi bookstore-web: sepete eklenen satır yumuşakça girecek, silinen satır kayarak çıkacak ve katalogda "sepete eklendi" bildirimi (toast) görünecek.

animate.enter ve animate.leave: CSS Class ile

bookstore-web'e yeni bir sepet sayfası ekledik (/cart). CartService artık her satıra bir lineId veriyor ve remove(lineId) method'u var. Template:

@for (line of lines(); track line.lineId) {
  <div class="cart-line" animate.enter="cart-line-enter" animate.leave="cart-line-leave">
    <span>{{ line.title }}</span>
    <span>{{ line.price | currency: 'TRY' : 'symbol-narrow' }}</span>
    <button type="button" (click)="remove(line)" i18n="@@cart.remove">Remove</button>
  </div>
} @empty {
  <p i18n="@@cart.empty">Your cart is empty.</p>
}
.cart-line-enter { animation: line-in 300ms ease-out; }
.cart-line-leave { animation: line-out 250ms ease-in forwards; }

@keyframes line-in {
  from { opacity: 0; transform: translateY(-8px); }
}
@keyframes line-out {
  to { opacity: 0; transform: translateX(24px); }
}

animate.enter element DOM'a (tarayıcının sayfayı tuttuğu element ağacı) girdiğinde verdiğin class'ı ekliyor, animasyon bitince kaldırıyor. animate.leave ise element'i kaldırmadan önce class'ı ekliyor ve animasyonun bitmesini bekliyor. Animasyonun kendisi tamamen CSS; Angular yalnızca class'ı doğru anda ekleyip çıkarıyor. Değeri sabit bir string olarak verebileceğin gibi [animate.enter]="enterClass()" şeklinde bağlayabilir ya da birden fazla class verebilirsin.

35-enter-leave-tr.png

Chromium'da katalogdan iki kitap ekleyip sepete geçtiğimizde satırların class'ları ilk anda cart-line cart-line-enter, 400 milisaniye sonra yalnızca cart-line idi. İlk satırın "Remove" butonuna basınca element'in class'ı cart-line cart-line-leave oldu ve element 279 milisaniye sonra DOM'dan çıktı: 250 milisaniyelik animasyon artı birkaç frame.

Bu API Angular 20.2 ile geldi; 22.2.6 paketinin tip tanımlarında AnimationCallbackEvent ve MAX_ANIMATION_TIMEOUT @publicApi 20.2 olarak işaretli. Dikkat edersen main.ts'e hiçbir animasyon provider'ı eklemedik ve @angular/animations'tan tek bir import yok. Yeni API Angular'ın çekirdeğinde.

(animate.leave) ile Web Animations API: Toast Bildirimleri

Bazen animasyonu CSS class'ı yerine JavaScript'te oynatmak istersin: süreyi hesaplamak, element'in o anki boyutunu kullanmak ya da bir library çağırmak için. Bunun için event binding hâli var. Katalogda sepete eklenen her kitap için bir toast gösteren component:

@Component({
  selector: 'app-toast-host',
  styleUrl: './toast-host.component.css',
  template: `
    @for (toast of toasts.items(); track toast.id) {
      <p class="toast" role="status" animate.enter="toast-enter" (animate.leave)="slideOut($event)">
        {{ toast.text }}
      </p>
    }
  `
})
export class ToastHostComponent {
  protected readonly toasts = inject(ToastService);

  protected slideOut(event: AnimationCallbackEvent): void {
    if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
      event.animationComplete();
      return;
    }
    const animation = event.target.animate(
      [{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'translateY(16px)' }],
      { duration: 200, easing: 'ease-in' }
    );
    // Angular element'i ancak bu çağrıdan sonra kaldırır
    animation.finished.then(() => event.animationComplete());
  }
}

$event bir AnimationCallbackEvent: target çıkan element, animationComplete ise "işim bitti, kaldırabilirsin" fonksiyonu. ToastService her toast'ı üç saniye sonra listeden çıkarıyor. Tarayıcıda "Add to cart"a basınca toast toast toast-enter class'ıyla geldi, ekranda "Dune added to the cart" ve "Emma added to the cart" yazdı. İlk toast'ın DOM'dan çıkması 3155 milisaniye sürdü: üç saniyelik bekleme, 200 milisaniyelik çıkış animasyonu ve birkaç frame. Toast metni de 34. yazıdaki gibi $localize ile işaretli; Türkçe build'de "Dune sepete eklendi" yazıyor.

prefers-reduced-motion: Hareketi Azaltmak İsteyenler

İşletim sisteminde "hareketi azalt" ayarını açan kullanıcılar var; bazıları için kayan, büyüyen element'ler baş dönmesi yapıyor. CSS animasyonları için tek bir media query yeterli:

@media (prefers-reduced-motion: reduce) {
  .cart-line-enter, .cart-line-leave { animation: none; }
  .free-shipping { transition: none; }
}

Toast'taki JavaScript animasyonu bu CSS'i görmüyor; o yüzden slideOut'un başında matchMedia ile aynı tercihi kontrol edip animationComplete()'i hemen çağırdık. Playwright'ta reducedMotion: 'reduce' açıkken bir satırı sildiğimizde element yine cart-line-leave class'ını aldı, ama 19 milisaniyede DOM'dan çıktı; animasyon olmadığı için Angular beklemedi.

Saf CSS Yolu: @starting-style ve transition-behavior

Her giriş-çıkış için Angular'a ihtiyaç yok. DOM'dan hiç çıkmayan, yalnızca display'i değişen bir element'i CSS tek başına canlandırabiliyor. Sepette toplam 500 TL'yi geçince görünen ücretsiz kargo satırı:

<p class="free-shipping" [hidden]="total() < 500" i18n="@@cart.freeShipping">Your order ships for free.</p>
.free-shipping {
  opacity: 1;
  transition: opacity 400ms, display 400ms allow-discrete;
}
.free-shipping[hidden] {
  display: none;
  opacity: 0;
}
@starting-style {
  .free-shipping { opacity: 0; }
}

@starting-style element ilk göründüğünde geçişin hangi değerden başlayacağını söylüyor. allow-discrete ise normalde anında değişen display'in geçiş bitene kadar beklemesini sağlıyor. CSS3 (Flexbox, Grid ve Modern CSS) serisinin 13. yazısı bu ikisini ayrıntılı anlatıyor. Dune (450) ve Emma (320) sepetteyken satır görünüyordu. Dune'u silince toplam 320'ye düştü ve hidden true oldu; o anda ölçtüğümüz değerler {"display":"block","opacity":"0.08","hidden":true} idi, 500 milisaniye sonra {"display":"none","opacity":"0"}. Yani element, gizlendiği hâlde opacity geçişi bitene kadar ekranda kaldı.

Route Geçişleri: withViewTransitions

Sayfalar arası geçiş için tarayıcının View Transitions API'si var ve Angular router bunu tek bir feature ile kullanıyor:

provideRouter(routes, withPreloading(PreloadAllModules), withComponentInputBinding(), withViewTransitions()),
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 200ms;
}

Router her navigasyonda document.startViewTransition'ı çağırıyor; tarayıcı eski ve yeni sayfanın görüntüsü arasında varsayılan olarak bir geçiş yapıyor. Bunu saymak için Playwright'ta startViewTransition'ı saran küçük bir script yükledik. Katalogdan sepete, oradan "About" sayfasına geçtikten sonra sayaç 3'tü: iki tıklama ve ilk açılış navigasyonu. İlk açılıştaki geçişi istemiyorsan skipInitialTransition seçeneği var. withViewTransitions 22.2.6'da hâlâ @developerPreview 19.0 olarak işaretli; davranışı küçük sürümlerde değişebilir. CSS tarafı CSS3 serisinin 14. yazısında.

Eski @angular/animations Kodunu Okumak

Angular 20.2 öncesinde yazılmış bir projede aynı sepet satırı muhtemelen şöyle görünür:

@Component({
  selector: 'app-legacy-cart',
  animations: [
    trigger('cartLine', [
      transition(':enter', [
        style({ opacity: 0, transform: 'translateY(-8px)' }),
        animate('300ms ease-out')
      ]),
      transition(':leave', [
        animate('250ms ease-in', style({ opacity: 0, transform: 'translateX(24px)' }))
      ])
    ])
  ],
  template: `
    @for (line of lines(); track line.lineId) {
      <div class="cart-line" @cartLine>{{ line.title }}</div>
    }
  `
})
class LegacyCart { /* ... */ }

trigger bir animasyona ad veriyor, transition(':enter') ve transition(':leave') giriş ve çıkış durumlarını, style ve animate da adımları tanımlıyor. Bunun çalışması için uygulamaya provideAnimationsAsync() (ya da eski provideAnimations()) eklenmesi gerekiyordu; testte provideNoopAnimations() ile çalıştırdık. Taşıma çoğu zaman düz: :enter'daki stiller bir @keyframes'in from'u, :leave'deki stiller to'su oluyor; @cartLine yerine animate.enter ve animate.leave geliyor.

Angular 22.2.6'nın tip tanımlarında @angular/animations'ın public API'leri ve provideAnimationsAsync "animate.enter ya da animate.leave kullanın" notuyla 20.2'den beri deprecated; notta paketin v23'te kaldırılmasının planlandığı da yazıyor. Yeni API bu provider'lara ihtiyaç duymuyor; bütün component'leri taşıdığında provider'ı da, paketi de kaldırabilirsin. bookstore-web'de @angular/animations Angular 12 ile oluşturduğumuz ilk projeden beri package.json'da duruyordu, ama hiçbir dosya onu import etmiyordu; grep boş döndü. npm uninstall @angular/animations ile kaldırdık; ng build ve 64 dosyadaki 79 test yine geçti. Kendi projende aynı kontrolü yap: kullanılmayan bir paket her ng update'te yine de güncellenmeyi ve uyumluluk kontrolünü bekliyor.

Taşımayı component component yapabilirsin, ama aynı component'te ikisini birden kullanamazsın. Hem animations property'si hem template'te animate.enter olan bir component'i derlediğimizde:

✘ [ERROR] NG2027: A component cannot have both the '@Component.animations' property (legacy animations) and use 'animate.enter' or 'animate.leave' in the template. [plugin angular-compiler]

Üretimde Dikkat

Unit testlerde animasyon yok. Angular'ın animasyon rehberine göre TestBed animasyonları varsayılan olarak kapatıyor; CartComponent'in testinde "Remove"a bastıktan hemen sonra satır listeden çıkmıştı ve ücretsiz kargo satırının hidden'ı true olmuştu. Bu iyi bir şey: testler animasyon süresi kadar beklemiyor. Animasyonun kendisini test etmek istiyorsan rehberdeki animationsEnabled: true ayarına ya da gerçek tarayıcıda çalışan bir E2E (end-to-end; uygulamayı kullanıcı gibi baştan sona kullanan) testine ihtiyacın var.

Leave animasyonu süresince element DOM'da kalıyor. Üç satırlık sepette ilk satır kayarken içindeki "Remove" butonuna ikinci kez tıkladık; remove(lineId) ID ile filtrelediği için başka satır silinmedi ve sepette Emma ile Dune kaldı. Index ile silen bir method olsaydı ikinci tıklama yanlış satırı silebilirdi. Toast'ları role="status" ile işaretledik; ekran okuyucular bu rolü taşıyan bir element'in içeriği değiştiğinde kullanıcının o anki işini bölmeden okuyor. Animasyon yalnızca görsel tarafı değiştiriyor, duyurunun kendisi bu attribute'tan geliyor. Son olarak animasyonları kısa tut: 200–300 milisaniye fark edilir ama kullanıcıyı bekletmez.

Sık Düşülen Tuzaklar

Baştaki soru: track $index. Sepette Dune ve Emma varken ilk satırın "Remove" butonuna bastık ve cart-line-leave class'ını alan satırın metnini kaydettik:

rows before: ["Dune","Emma"]
clicked Remove on the first row; leaving row text: ["Emma"]
rows after: ["Emma"]

Ekranda Emma satırı kayarak çıktı, Dune satırının metni ise bir anda Emma oldu. Index ile takip edilen listede silinen şey "son index" olduğu için animasyon yanlış satırda oynuyor. Çözüm satırı kendi kimliğiyle takip etmek: track line.lineId.

Tersi de var. track line ile object referansını takip edip service'in her değişiklikte satırları yeni object olarak yayınladığını düşün (API'den her seferinde yeniden çekilen bir liste gibi). Üç satırlık sepette ilkini sildiğimizde:

["Emma: cart-line cart-line-enter","Dune: cart-line cart-line-enter","Dune: cart-line cart-line-leave","Emma: cart-line cart-line-leave","Dune: cart-line cart-line-leave"]

Kalan iki satır yeniden yaratılıp giriş animasyonu aldı, eski üç satırın hepsi çıkış animasyonu aldı. Liste bir anda beş satır gibi görünüp hep birlikte hareket ediyor. ID ile takip ikisini de çözüyor. 25. yazıda tanıdığımız, 27. yazıda stable olan @for'da track'in zorunlu olduğunu görmüştük; animasyonlu bir listede doğru ifadeyi seçmenin farkı ekranda görünüyor.

Son tuzak animationComplete()'i unutmak. Toast'taki satırı animation.finished.then(() => console.log(...)) yapıp ölçtük: toast 6963 milisaniye sonra kalktı. Üç saniyelik beklemeye bir de MAX_ANIMATION_TIMEOUT'un dört saniyesi eklendi. Daha kötüsü, 200 milisaniyelik animasyon bitince fill verilmediği için element opacity 1'e döndü; çıkış animasyonundan sonra toast yaklaşık dört saniye daha ekranda göründü.

Küçük Bir Deneme

★☆☆ Sepet satırının giriş animasyonunu soldan kayarak gelecek şekilde değiştir, sonra işletim sisteminde ya da tarayıcının geliştirici araçlarında "reduced motion"ı açıp animasyonun kapandığını gör.

★★☆ Ücretsiz kargo satırını [hidden] yerine @if ile göster ve aynı efekti animate.enter/animate.leave ile kur. İki yaklaşımın DOM'da ne bıraktığını karşılaştır.

★★★ Eski bir projendeki trigger tabanlı bir animasyonu seç, aynı component'te animations property'sini kaldırıp CSS class'larına taşı ve NG2027 hatası almadan derlendiğini gör.

Bu yazıyla serinin "Angular 22 ile pratik konular" bloğu tamamlanıyor. Angular 12 ile temelleri kurduk (01–19), sürüm sürüm Angular 22'ye kadar neyin değiştiğini gördük (20–31) ve son dört yazıda Angular 22 ile günlük işte karşına çıkacak konuları bookstore-web üzerinde çalıştık: Signal Forms ile checkout, kendi form control'lerin, iki dilli arayüz ve animasyon. Buradan devam etmek istersen Angular'da State Yönetimi, Frontend Testleri ve Angular Material ve CDK (Component Dev Kit; Angular ekibinin component davranış araçları) serileri aynı uygulamanın üzerine kuruluyor. Angular 23 çıktığında, 31. yazıda söylediğimiz gibi, sürüm yazılarıyla bu seride devam edeceğiz.