IlmHamroh
JavaScript Full-stack/20-qism. React: UI kutubxonalar va animatsiya22/30-dars23 daqiqa
Mundarija (34)

CSS animatsiyalari React'da: transition, keyframes, data-state va @starting-style bilan kirish-chiqish

Qisqacha: React'da ko'p animatsiyaga CSS yetadi. Holat o'zgarsa — className yoki data-* atributi almashadi, transition qolganini qiladi. Element paydo bo'lganda transition ishlamaydi — buning uchun @keyframes yoki @starting-style. Element yo'qolganda esa React uni darhol o'chiradi: chiqishni kutish uchun animationend (Radix'ning data-state="closed" i shunday ishlaydi). <dialog> va popover uchun esa transition-behavior: allow-discrete yetadi — display ham animatsiya oxirigacha kechikadi. Qolgan murakkab holatlar — Motion.

Bu darsda

  • className va data-* bilan transition qilasiz va nega yangi element "animatsiyasiz" paydo bo'lishini tushuntirasiz.
  • Kirish animatsiyasini @keyframes va @starting-style bilan, qayta ishga tushirishni key bilan qilasiz.
  • Chiqish animatsiyasini React'da to'g'ri qurasiz: avval animatsiya, keyin o'chirish.
  • Radix data-state va Tailwind (data-[state=open]:, tw-animate-css) bilan menyuni jonlantirasiz.
  • vazifalar dagi tasdiq oynasi, menyu va tooltip'ga kirish-chiqish animatsiyasini qo'shib, commit qilasiz.

Oldin bilishingiz kerak: UI animatsiya tamoyillari va performance, Radix Primitives chuqur, @keyframes va animation xususiyatlari, Kirish va chiqish animatsiyalari: @starting-style.

1. Nega bu kerak?

Oldingi darsda qoidalarni o'rgandik: 150–300 ms, ease-out, faqat transform va opacity. Endi ularni React komponentlarida qo'llaymiz.

Sardor CSS qismidan transition'ni yaxshi biladi. React'da esa ikki joyda qoqildi. Birinchisi: filtr paneli {open && <Panel />} bilan chiqadi va transition yozilgan bo'lsa ham "sakrab" paydo bo'ladi. Ikkinchisi: toast yopilganda chiqish animatsiyasi umuman ko'rinmaydi — toast shunchaki yo'qoladi.

Ikkala muammo bitta sababdan: React elementni DOM'ga qo'yadi va olib tashlaydi. CSS transition esa "bir holatdan ikkinchisiga" ishlaydi. Element hali yo'q bo'lsa, "oldingi holat" ham yo'q. Element yo'q bo'lsa, "keyingi holat" ni chizadigan narsa ham yo'q.

Hayotiy o'xshatish: teatr pardasi. Aktyor sahnaga chiqishi uchun parda ochiladi. Lekin aktyor sahnaning orqasida turmasa — u to'satdan "paydo bo'ladi". Ketishda ham: sahnadan chiqib ketishiga vaqt berish kerak, uni bir zumda yo'q qilib bo'lmaydi.

2. Holat o'zgarishi: className va data-*

2.1 Transition — CSS'dagidek

Element joyida turadi, faqat holati o'zgaradi. Bu CSS transition'ning eng qulay ishi. React holatni sinf yoki atributga aylantiradi, qolganini CSS qiladi:

jsx
import { useState } from "react";

const css = `
  .star {
    border: 1px solid #bbb;
    border-radius: 8px;
    background: #fff;
    padding: 8px 12px;
    transition: scale 150ms ease-out, background-color 150ms;
  }
  .star[aria-pressed="true"] {
    scale: 1.05;
    background-color: #fde68a;
  }
`;

export default function App() {
  const [fav, setFav] = useState(false);
  return (
    <div>
      <style>{css}</style>
      <button
        className="star"
        aria-pressed={fav}
        onClick={() => setFav(!fav)}
      >
        Sevimli
      </button>
      <p>Osh {fav ? "sevimlilarda" : "sevimlilarda emas"}</p>
    </div>
  );
}

Ekranda:

text
Sevimli
Osh sevimlilarda

«Ishga tushir» dan keyin tugmani bosing — u bir oz kattalashib sariq bo'ladi, qayta bosing — joyiga qaytadi. Uslub uchun alohida sinf emas, aria-pressed atributini ishlatdik. Bu atribut Malika uchun "bosilgan" holatini ham aytadi. Bitta haqiqat — ham a11y, ham ko'rinish (React'da accessibility darsidagi g'oya).

scale — transform: scale() ning alohida xususiyati: kompozitorda bajariladi. background-color — bo'yash bosqichi, lekin kichik tugmada arzon.

2.2 onTransitionEnd va onAnimationEnd

React DOM'ning animatsiya hodisalarini ham beradi: onTransitionEnd, onAnimationStart, onAnimationEnd, onAnimationIteration. Ular "animatsiya tugadi" deb xabar beradi — chiqish animatsiyasida aynan shu kerak bo'ladi.

Ikki tuzoq bor:

  • Hodisa ko'tariladi (bubbling). Ichki elementning animatsiyasi tugasa ham, ota-elementning onAnimationEnd i chaqiriladi. Kerak bo'lsa e.target === e.currentTarget ni tekshiring.
  • Har xususiyat uchun alohida. transition: opacity, scale — ikkita transitionend. e.propertyName qaysi biri ekanini aytadi.

3. Kirish: element paydo bo'lganda

3.1 Muammo: transition'ga "oldingi holat" kerak

{open && <div className="panel">…</div>} — open true bo'lganda div DOM'ga birinchi marta qo'yiladi. Brauzer uni darhol yakuniy uslubda chizadi. opacity: 0 dan 1 ga o'tish uchun "oldin 0 edi" degan holat yo'q. Shuning uchun transition ishlamaydi.

Uchta yechim bor.

3.2 @keyframes — paydo bo'lganda o'zi ishlaydi

animation elementga qo'yilgan zahoti boshlanadi. Element DOM'ga kirdimi — animatsiya bo'ladi:

css
@keyframes kirish {
  from { opacity: 0; translate: 0 8px; }
}
.panel {
  animation: kirish 200ms ease-out;
}

to yozilmagan — yakuniy qiymat elementning oddiy uslubi. Kanon ham shu qisqa shaklni ishlatadi.

3.3 @starting-style — transition uchun "oldingi holat"

Kirish va chiqish animatsiyalari darsida o'rgangan @starting-style aynan shu muammo uchun yaratilgan: "element birinchi marta chizilganda shu qiymatlardan boshla". React qo'ygan element uchun ham ishlaydi:

jsx
import { useState } from "react";

const css = `
  .panel {
    padding: 8px;
    border: 1px solid #bbb;
    border-radius: 8px;
    transition: opacity 200ms ease-out, translate 200ms ease-out;
  }
  @starting-style {
    .panel { opacity: 0; translate: 0 8px; }
  }
`;

export default function App() {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <style>{css}</style>
      <button onClick={() => setOpen(!open)}>Filtr</button>
      {open && <div className="panel">Faqat bajarilmaganlar</div>}
    </div>
  );
}

Ekranda:

text
Filtr
Faqat bajarilmaganlar

Natija oynasida «Filtr» ni bosing — panel pastdan ko'tarilib, so'nib chiqadi. JavaScript'da hech qanday animatsiya kodi yo'q.

@keyframes va @starting-style farqi nimada? Transition to'xtatilishi mumkin: panel chiqayotganda yana bossangiz, joriy qiymatdan orqaga qaytadi. Keyframe animatsiyasi esa o'z yo'lini oxirigacha bosadi. Tez-tez ochib-yopiladigan narsaga transition yaxshiroq.

Uchinchi, eski usul: element avval "yopiq" sinf bilan qo'yiladi, keyingi kadrda (requestAnimationFrame yoki effekt) "ochiq" sinf beriladi. Eski kodda uchratasiz. Bugun @starting-style ning o'zi yetadi. Lekin u yangi: Baseline'ga 2024-yil avgustda kirgan, eski brauzerlarda yo'q (caniuse da tekshiring). U yerda element shunchaki animatsiyasiz paydo bo'ladi — hech narsa buzilmaydi.

3.4 Animatsiyani qayta boshlash: key

Savatga taom qo'shilganda raqam belgisi bir marta "lip" etsin. Keyframe animatsiyasi esa faqat element yangi paydo bo'lganda ishlaydi. Raqam 2 dan 3 ga o'zgarsa, element o'sha-o'sha — animatsiya qaytmaydi.

State'ning saqlanishi va key bilan reset darsidan bilasiz: key o'zgarsa, React eski elementni olib tashlab, yangisini yasaydi. Buni ataylab ishlatamiz:

jsx
import { useEffect, useState } from "react";

const css = `
  @keyframes lip { 50% { scale: 1.3; } }
  .badge { display: inline-block; animation: lip 200ms ease-out; }
`;

function Badge({ count }) {
  useEffect(() => {
    console.log("badge chizildi:", count);
  }, []);
  return <span className="badge">{count}</span>;
}

export default function App() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <style>{css}</style>
      <button onClick={() => setCount((c) => c + 1)}>
        Qo'shish
      </button>{" "}
      Savat: <Badge key={count} count={count} />
    </div>
  );
}

Konsolda:

text
badge chizildi: 0
badge chizildi: 1
badge chizildi: 2
badge chizildi: 3

Ekranda:

text
Qo'shish Savat: 3

Effekt bo'sh bog'liqlik bilan faqat yangi komponent paydo bo'lganda ishlaydi. Konsolda to'rtta yozuv — har bosishda belgi yangidan yasaldi va animatsiya qaytadan boshlandi. display: inline-block nega? scale oddiy <span> ga (inline element) ta'sir qilmaydi.

Narxi: key almashganda butun komponent va uning ichidagi state yo'qoladi. Kichik belgi uchun muammo emas. Katta komponentni shunday "qayta tug'dirmang".

Tekshirib ko'ring: Sardor menyu ro'yxatiga transition: opacity 0.2s va opacity: 1 yozdi. Ro'yxat {open && …} bilan chiqadi. Animatsiya ko'rinmadi. Eng kam o'zgarish bilan qanday tuzatasiz?

Javob

@starting-style { .menu { opacity: 0; } } qo'shish yetadi. Element birinchi chizilganda brauzer opacity: 0 dan boshlaydi va transition 1 gacha olib boradi. Yoki transition o'rniga animation: kirish 0.2s + @keyframes kirish { from { opacity: 0 } }. Ikkalasi ham JavaScript'siz.

4. Chiqish: React o'chirishdan oldin

4.1 Avval animatsiya, keyin o'chirish

setOpen(false) — React elementni darhol DOM'dan olib tashlaydi. Chiqish animatsiyasi uchun tartibni o'zgartirish kerak:

  1. Holatni "chiqmoqda" ga o'tkazamiz — sinf almashadi va CSS chiqish animatsiyasini boshlaydi.
  2. Animatsiya tugaganda (onAnimationEnd) — haqiqiy o'chirish.
  3. Zaxira taymer: hodisa kelmasa ham (element yashirin, animatsiya bekor qilindi) — baribir o'chirish.
jsx
import { useRef, useState } from "react";

const css = `
  @keyframes kirish { from { opacity: 0; translate: 0 8px; } }
  @keyframes chiqish { to { opacity: 0; translate: 0 8px; } }
  .toast { animation: kirish 200ms ease-out; }
  .toast.chiqmoqda { animation: chiqish 150ms ease-in forwards; }
`;

export default function App() {
  const [open, setOpen] = useState(false);
  const [leaving, setLeaving] = useState(false);
  const fallback = useRef();

  function finish() {
    clearTimeout(fallback.current);
    setLeaving(false);
    setOpen(false);
  }

  function close() {
    setLeaving(true); // avval chiqish animatsiyasi
    fallback.current = setTimeout(finish, 300); // zaxira
  }

  const status = !open ? "yopiq" : leaving ? "chiqmoqda" : "ochiq";
  return (
    <div>
      <style>{css}</style>
      <button onClick={() => setOpen(true)}>Saqlash</button>{" "}
      <button onClick={close}>Yopish</button>
      {open && (
        <p
          className={leaving ? "toast chiqmoqda" : "toast"}
          onAnimationEnd={() => leaving && finish()}
        >
          Saqlandi
        </p>
      )}
      <p>Holat: {status}</p>
    </div>
  );
}

Ekranda:

text
Saqlash Yopish
Holat: yopiq

ekran("Holat: chiqmoqda") — oraliq nuqtani tekshirdi: «Yopish» dan keyin toast hali DOM'da, holat "chiqmoqda". Natija oynasida toast 150 ms da pastga so'nib ketadi va onAnimationEnd uni o'chiradi. Vaqt bo'yicha:

Qadam Holat Ekranda
«Saqlash» bosildi open, kirish 200 ms "Saqlandi" pastdan chiqadi
«Yopish» bosildi leaving, chiqish 150 ms "Saqlandi" so'nmoqda, DOM'da bor
animationend (yoki 300 ms) finish() — yopiq toast yo'q, "Holat: yopiq"

Muhim detallar:

  • forwards — animatsiya tugagach oxirgi kadrda qoladi. Aks holda React o'chirguncha bir kadr davomida toast to'liq ko'rinib, "miltillaydi".
  • Zaxira taymer — fon tabda yoki element yashirilgan bo'lsa, brauzer animatsiyani o'tkazib yuborishi va hodisa kechikishi mumkin. Kanon ham o'chirish animatsiyasini ko'pi bilan 300 ms kutadi (Motion I darsida ko'ramiz).
  • Reduced-motion — global qoida animatsiyani 0.01 ms qiladi, hodisa darhol keladi. Agar animation: none bo'lsa, hodisa umuman kelmaydi va faqat zaxira taymer qutqaradi.

Bu kod — Motion'ning AnimatePresence komponenti ichida qiladigan ishning eng sodda shakli. Motion III darsida uni kutubxona bilan qilamiz va uning ro'yxatdagi tuzog'ini o'lchaymiz.

4.2 <dialog> va popover: allow-discrete

<dialog> va popover da vaziyat boshqacha: element DOM'da qoladi, faqat yopilganda display: none bo'ladi. display esa "diskret" xususiyat — oraliq qiymati yo'q (block va none orasida "yarim" yo'q). Shuning uchun u sukut bo'yicha darhol almashadi va chiqish animatsiyasi ko'rinmay qoladi.

transition-behavior: allow-discrete (qisqa yozuvda display 0.2s allow-discrete) — "diskret xususiyatni ham o'tishga qo'sh". Brauzer display: none ni o'tish oxirigacha kechiktiradi. overlay — top layer'dan chiqish ham kechiksin (Top layer darsida). Kirish uchun esa yana @starting-style.

JavaScript'siz to'liq misol — HTML'ning o'z popovertarget atributi bilan:

html
<style>
  body { font-family: system-ui, sans-serif; }
  [popover] {
    padding: 0.75rem 1rem;
    border: 1px solid #bbb;
    border-radius: 8px;
    opacity: 0;
    translate: 0 0.5rem;
    transition:
      opacity 0.2s ease-out,
      translate 0.2s ease-out,
      overlay 0.2s allow-discrete,
      display 0.2s allow-discrete;
  }
  [popover]:popover-open {
    opacity: 1;
    translate: 0 0;
  }
  @starting-style {
    [popover]:popover-open { opacity: 0; translate: 0 0.5rem; }
  }
  @media (prefers-reduced-motion: reduce) {
    [popover] { transition: none; }
  }
</style>
<button popovertarget="menyu">Bugungi menyu</button>
<div id="menyu" popover>
  <p>Osh — 35 000 so'm</p>
  <p>Manti — 30 000 so'm</p>
  <button popovertarget="menyu" popovertargetaction="hide">
    Yopish
  </button>
</div>

Tugmani bosing — menyu pastdan chiqadi. Tashqariga bosing, Esc yoki «Yopish» — u so'nib ketadi va keyin yo'qoladi. Uchta qism birga ishladi:

Qism Nima qiladi
:popover-open { opacity: 1 } ochiq holat
@starting-style kirish boshlanadigan qiymat
display … allow-discrete yopilganda display: none o'tish oxirigacha kechikadi

React'da <dialog> (showModal()) bilan xuddi shu CSS ishlaydi. vazifalar dagi tasdiq oynasi bugun shunday bo'ladi.

Tekshirib ko'ring: Toast misolida forwards ni olib tashlasak, chiqish oxirida nima ko'rinadi?

Javob

Animatsiya tugashi bilan element @keyframes siz uslubiga — to'liq ko'rinadigan holatga — qaytadi. onAnimationEnd dan keyin React uni o'chirguncha bir kadr o'tadi. Shu bir kadrda toast yana paydo bo'lib, keyin yo'qoladi — ko'zga "miltillash" bo'lib ko'rinadi. forwards oxirgi kadrni (opacity: 0) ushlab turadi.

5. Radix va Tailwind: data-state bilan animatsiya

5.1 Radix chiqishni kutadi

Radix Primitives chuqur darsida data-state="open"/"closed" bilan stil berdik. Animatsiya ham shu atributga bog'lanadi. Radix'da yana bir yordam bor: ichki Presence komponenti. Menyu yopilganda Radix data-state="closed" qo'yadi va qaraydi: elementda animatsiya bormi? Bo'lsa — animationend ni kutadi, keyin olib tashlaydi. Ya'ni «Avval animatsiya, keyin o'chirish» bo'limidagi ish Radix'da tayyor:

jsx
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

const css = `
  @keyframes kirish { from { opacity: 0; scale: 0.96; } }
  @keyframes chiqish { to { opacity: 0; scale: 0.96; } }
  .menu {
    padding: 4px;
    border: 1px solid #bbb;
    border-radius: 8px;
    background: #fff;
    transform-origin:
      var(--radix-dropdown-menu-content-transform-origin);
  }
  .menu[data-state="open"] { animation: kirish 120ms ease-out; }
  .menu[data-state="closed"] { animation: chiqish 100ms ease-in; }
`;

export default function App() {
  return (
    <div>
      <style>{css}</style>
      <DropdownMenu.Root modal={false}>
        <DropdownMenu.Trigger>Hisobot</DropdownMenu.Trigger>
        <DropdownMenu.Portal>
          <DropdownMenu.Content className="menu" align="start">
            <DropdownMenu.Item>CSV</DropdownMenu.Item>
            <DropdownMenu.Item>JSON</DropdownMenu.Item>
          </DropdownMenu.Content>
        </DropdownMenu.Portal>
      </DropdownMenu.Root>
    </div>
  );
}

Ekranda:

text
Hisobot

Menyu ochildi va Esc bilan yopildi — oxirida ekranda faqat tugma. Natija oynasida o'zingiz bosing: menyu tugmadan "o'sib" chiqadi va yopilganda 100 ms so'nadi. --radix-dropdown-menu-content-transform-origin — Radix bergan "o'sish nuqtasi": menyu qaysi tomonda ochilsa, o'sha tomondagi tugmadan o'sadi.

5.2 Kanon topgan tuzoq: kirish paytida Esc

vazifalar da aynan shunday qilinganda (kirish ham, chiqish ham) e2e tekshiruv yiqildi: menyu ochila boshlagan zahoti Esc bosilsa, menyu yopildi, lekin fokus tugmaga qaytmadi — bandda qoldi. Sabab: kirish animatsiyasi paytida Esc bosilsa, Radix yopishni animatsiya tugashini (animationend) kutib kechiktirardi. Shu orada fokus bandda qolib ketdi:

Qadam Fokus / Holat Ekranda
menyu klaviaturadan ochildi fokus bandda, kirish (120 ms) boshlandi menyu o'sib chiqmoqda
darhol Esc data-state="closed", Radix animationend ni kutadi menyu hali ko'rinadi
animatsiya tugadi menyu yo'q, fokus bandda qolgan Bobur fokusni yo'qotdi

Kanon yechimi — animatsiya faqat data-state="open" da. Yopilganda animatsiya nomi none bo'ladi, Radix kutmaydi va menyuni darhol olib tashlaydi. Menyu kuniga yuz marta ochiladi va yopilganda unga hech kim qaramaydi. Chiqish animatsiyasidan voz kechish — arzon narx.

Bu e2e tekshiruvlarsiz topilmasdi: ko'z bilan sinab ko'rganda hammasi chiroyli edi.

5.3 Tailwind'da: data-variant va tw-animate-css

Tailwind v4 da (Tailwind CSS 2: variantlar) xuddi shu qoidalar:

tsx
<DropdownMenuContent
  className="origin-(--radix-dropdown-menu-content-transform-origin)
    data-[state=open]:motion-safe:animate-kirish"
/>
  • data-[state=open]: — [data-state="open"] selektori.
  • motion-safe: — faqat prefers-reduced-motion: no-preference da. Teskarisi — motion-reduce:.
  • animate-kirish — @theme dagi --animate-kirish tokenidan (Tailwind CSS 3 darsida).

shadcn komponentlarida esa boshqa yozuvni ko'rasiz:

tsx
"data-[state=open]:animate-in data-[state=closed]:animate-out
 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
 data-[state=open]:zoom-in-95"

Bu sinflar tw-animate-css paketidan: animate-in/animate-out — umumiy kirish/chiqish animatsiyasi, fade-in-0, zoom-in-95, slide-in-from-top-2 — uning parametrlari. Tailwind v3 davrida bu ish tailwindcss-animate plagini bilan qilinardi. v4 da shadcn shu paketga o'tdi va CSS'da @import "tw-animate-css" qatori qo'shiladi.

Kanon tw-animate-css'ni olmadi. Bitta 120 ms lik kirish uchun @theme dagi 4 qatorlik token yetdi. Chiqish esa yuqoridagi sabab bilan ataylab yo'q.

6. CSS yoki JavaScript?

6.1 Qaror

CSS yetadi JavaScript (Motion) kerak
hover, focus, bosish javobi to'xtatilganda tezlikni saqlash (spring)
ochish/yopish: menyu, tooltip sudrash, surib tashlash (gesture)
kirish: @keyframes, @starting-style ro'yxatda qatorlar joyini almashtirish (layout)
<dialog>/popover chiqishi: allow-discrete ixtiyoriy React elementining chiqishi
loader, skeleton ketma-ketlik va orkestratsiya ma'lumotga bog'liq
flowchart TD
  A{"Sudrash, spring yoki joy almashishi?"} -->|ha| M["Motion"]
  A -->|yo'q| B{"Element yo'qoladimi?"}
  B -->|"dialog, popover, Radix"| C["CSS: allow-discrete / data-state"]
  B -->|"oddiy React elementi"| D["CSS + onAnimationEnd yoki Motion"]
  B -->|yo'q| E["CSS: transition, keyframes"]

Sxemada nimaga qarang: ko'p yo'l CSS'ga olib boradi. CSS animatsiyasining afzalliklari: 0 KB JavaScript, kompozitorda ishlaydi (asosiy oqim band bo'lsa ham), CSP bilan muammosiz (.css faylda).

6.2 Kanonning tanlovi

vazifalar da shu qoida qo'llandi. Tasdiq oynasi, menyu, tooltip va toast — CSS. Faqat ro'yxatning yangi qatori — Motion (keyingi darslarda). O'chirilayotgan qator chiqishi esa brauzerning Web Animations API'si bilan (Web Animations API).

Tekshirib ko'ring: "Yangi vazifa ro'yxatning tepasiga qo'shilsa, qolgan qatorlar silliq pastga sursin" — CSS bilan bo'ladimi?

Javob

Oddiy CSS bilan — yo'q. Yangi qatorning o'zini @keyframes bilan jonlantirish mumkin. Lekin qolgan qatorlarning joyi layout natijasida o'zgaradi va ularning "oldingi joyi" CSS'ga noma'lum. Buning uchun FLIP kerak: eski joyni o'lchash, yangi joyga qo'yish, farqni transform bilan orqaga qaytarib, nolga animatsiya qilish. Motion'ning layout props'i shuni qiladi (Motion III). View Transitions API ham bunga yaqin imkon beradi (View Transitions darsida).

7. Vazifalar qadami: dialog, menyu va tooltip animatsiyasi (holat 22)

7.1 Tasdiq oynasi: kirish VA chiqish

Oldingi darsda feat/css-animatsiya branch'ini ochib, toast'ni tayyorlagan edik. Endi tasdiq oynasi. 13-qismdan beri u faqat @keyframes bilan kirardi, yopilganda esa darhol yo'qolardi. asos.css (kanon, holat 22):

diff
-dialog[open] { animation: dialog-kirish 0.15s ease-out; }
-@keyframes dialog-kirish {
-  from { opacity: 0; transform: translateY(0.5rem); }
+/* 20/#22: kirish VA chiqish — CSS o'tishi (13/#09 dagi @keyframes
+   faqat kirishni ko'rsatardi: yopilganda oyna darhol yo'qolardi).
+   Yopiq <dialog> — display: none; display va overlay (top layer)
+   "allow-discrete" bilan o'tish oxirigacha kechikadi.
+   @starting-style — ochilishdagi boshlang'ich qiymat (display: none
+   dan keyin birinchi kadr). Faqat opacity va transform — kompozitor
+   (layout yo'q) */
+dialog {
+  opacity: 0;
+  transform: translateY(0.5rem);
+  transition:
+    opacity 0.15s ease-out,
+    transform 0.15s ease-out,
+    overlay 0.15s allow-discrete,
+    display 0.15s allow-discrete;
+}
+dialog[open] { opacity: 1; transform: none; }
+@starting-style {
+  dialog[open] { opacity: 0; transform: translateY(0.5rem); }
+}
+dialog::backdrop {
+  transition:
+    background-color 0.15s,
+    overlay 0.15s allow-discrete,
+    display 0.15s allow-discrete;
+}
+@starting-style {
+  dialog[open]::backdrop { background-color: transparent; }
 }

(Kanon izohi telefonga sig'ishi uchun qatorlarga qayta bo'lingan, mazmuni o'sha.) Popover misolidagi uchlik: yopiq holat, [open] holat, @starting-style. Fon (::backdrop) ham alohida o'tadi: qora parda so'nib paydo bo'ladi.

7.2 Topilma: ::backdrop va reduced-motion

Global reduced-motion qoidasi *, ::before, ::after uchun yozilgan edi. ::backdrop bu ro'yxatga kirmaydi — fon o'tishi harakatni kamaytirgan foydalanuvchida ham 0.15 s qoldi. Birinchi tuzatish — ro'yxatga ::backdrop ni qo'shish — ishlamadi. Build'dagi CSS optimallashtiruvchi uni jimgina tashlab yubordi. e2e tekshiruvi buni ushladi. Yechim — alohida qoida:

diff
+  /* 20/#22: ::backdrop — "*" ga kirmaydi (dialog foni ham o'tadi).
+     ALOHIDA qoida: ro'yxatda (*, ::before, ::after, ::backdrop)
+     bo'lsa, build'dagi CSS optimallashtiruvchi ::backdrop ni
+     jimgina tashlab yubordi (o'lchandi, e2e-20: fon o'tishi
+     reduced-motion'da ham 0.15s qoldi) */
+  ::backdrop {
+    transition-duration: 0.01ms !important;
+    animation-duration: 0.01ms !important;
+    animation-iteration-count: 1 !important;
+  }

Saboq: CSS'ni ko'z bilan emas, build natijasida tekshiring. Dev rejimda ishlagan qoida production'da yo'q bo'lishi mumkin.

7.3 Menyu va tooltip: faqat kirish

vazifalar.css dagi @theme ga yangi animatsiya tokeni:

diff
+  /* 20/#22: menyu va tooltip ochilishi — qisqa (120 ms),
+     faqat opacity va scale. Chiqish animatsiyasi yo'q: Radix
+     yopilganda elementni darhol olib tashlaydi (animatsiya
+     bo'lsa, animationend kutadi) */
+  --animate-kirish: kirish 0.12s ease-out;
+  @keyframes kirish {
+    from { opacity: 0; scale: 0.96; }
+  }

ui/dropdown-menu.tsx dagi DropdownMenuContent sinflariga ikki narsa qo'shildi — o'sish nuqtasi va kirish (kanon qatori uzun, faqat yangi qismlari):

tsx
// 20/#22: origin — trigger tomondan o'sadi; motion-safe —
// harakatni kamaytirish so'ralmagan bo'lsa. Faqat data-state=open
// da: yopilganda animatsiya nomi "none" bo'ladi va Radix kutmasdan
// olib tashlaydi — aks holda kirish animatsiyasi paytidagi Esc
// animationend'ni kutardi (e2e-18 topdi: menyu yopildi, fokus
// bandda qoldi)
"… origin-(--radix-dropdown-menu-content-transform-origin) …
 data-[state=open]:motion-safe:animate-kirish"

ui/tooltip.tsx da ham xuddi shunday, faqat tooltip'ning ochiq holatlari ikkita: data-[state=delayed-open] (kutib ochildi) va data-[state=instant-open] (darhol — boshqa tooltip'dan o'tilganda).

7.4 Tekshiruvlar va commit

bash
git add -A
git commit

Commit (kanon): feat: dialog kirish/chiqish (@starting-style, allow-discrete), menyu animatsiyasi. Diff: 4 fayl, +53 −7.

Tekshiruvlar (haqiqiy, holat 22):

  • npm test — node:test pass 203, Vitest Tests 65 passed (65); lint, format:check, tip, knip — exit 0.
  • Build: birinchi yuklash JS 186.7 KB, CSS 4.6 KB (gzip) — animatsiya CSS'da, JavaScript qo'shilmadi.
  • axe — 8 ko'rinishda 0 buzilish. Ko'rinish solishtiruvi (17 → 22): 236 element, farq 4 — yopiq dialog va buyruqlar oynasining opacity si 1 → 0 (yangi yopiq holat).
  • Brauzer: 159, 72, 100 va 20-qism ssenariysi (e2e-20) — 68/68. Halol izoh: Ctrl+K ning rus tartibidagi tekshiruvi ssenariyga keyinroq qo'shilgan (Command palette darsida), holat 22 esa u bilan qayta yurgizilmagan. U bilan 69 ta bo'lardi — o'sha tekshiruv holat 11 va 25 da o'tgan.

Quyida ssenariyning animatsiya bo'limi (Chrome 155). Har qatorda tekshiruv nomi va o'lchangan qiymat — ikkinchi va uchinchi qatorga alohida qarang:

text
✅ dialog ochilishi: opacity va transform o'tishi (150 ms) — @starting-style: ["opacity:150","transform:150"]
✅ Esc: open=false, lekin chiqish o'tishi davomida ko'rinadi (display allow-discrete): [false,true]
✅ chiqishdan keyin: display none, fokus tugmada: ["none","ochir"]
✅ menyu: kirish animatsiyasi «kirish» (120 ms): ["kirish","0.12s"]
✅ toast: o'tish — opacity va translate (kompozitor): "opacity, translate"
✅ animatsiya: CSP buzilishi yo'q: []
✅ reduced-motion: dialog va fon o'tishi ~0 (0.01 ms): ["1e-05s","1e-05s"]
✅ reduced-motion: Esc — darhol yopildi (display none): "none"
✅ reduced-motion: menyu animatsiyasiz (motion-safe): "none"
✅ animatsiya (reduced-motion): CSP buzilishi yo'q: []

Nima bo'ldi? Ikkinchi qatorga qarang: open allaqachon false, lekin oyna hali ko'rinadi — allow-discrete ishladi. Uchinchisida — chiqish tugadi, display: none, fokus «O'chirish» tugmasiga qaytdi. 1e-05s — 0.01 ms ning soniyadagi yozuvi (0.00001 s). CSP buzilishi yo'q, chunki hamma animatsiya .css faylda.

8. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
{open && …} bilan chiqqan element animatsiyasiz transition'ga oldingi holat yo'q @starting-style yoki @keyframes
chiqish animatsiyasi ko'rinmaydi React elementni darhol o'chiradi avval "chiqmoqda" holati, keyin o'chirish
chiqish oxirida "miltillash" forwards yo'q animation-fill-mode: forwards
dialog yopilganda darhol yo'qoladi display diskret display … allow-discrete, overlay
onAnimationEnd kutilmaganda chaqiriladi ichki elementdan ko'tarildi e.target === e.currentTarget
element o'chmay qoldi animationend kelmadi (animation: none, fon tab) zaxira taymer
Radix menyu Esc'dan keyin fokus bandda kirish animatsiyasi paytida yopish animatsiya faqat data-state=open da
scale <span> da ishlamaydi inline element display: inline-block

9. Mashqlar

Mashqlar kurs/mashqlar/20/22-css-animatsiya/ da. Har yechimni «Ishga tushir» bilan sinang.

1-mashq (oson): qaysi vosita?

Vaziyatga mos CSS vositasini yozing: @starting-style, allow-discrete, key yoki forwards.

  • <dialog> yopilganda so'nib ketishi uchun →
  • {open && <Panel />} paydo bo'lganda transition ishlashi uchun →
  • Har yangi xabarda belgi qayta "lip" etishi uchun →
  • Chiqish animatsiyasi oxirida element ko'rinmay qolishi uchun →
Yechim
  • allow-discrete — display: none o'tish oxirigacha kechikadi.
  • @starting-style — birinchi chizilish uchun boshlang'ich qiymat.
  • key — komponent yangidan yasaladi, keyframe qayta boshlanadi.
  • forwards — oxirgi kadr ushlab turiladi, React o'chirguncha miltillamaydi.

2-mashq (o'rta): xato xabari "titrasin"

Login formasida parol noto'g'ri bo'lsa, xato xabari chapga-o'ngga bir marta titrasin. Har yangi urinishda — qaytadan.

  1. @keyframes titrash — translate bilan: 0 → −6px → 6px → −6px → 0.
  2. Har noto'g'ri urinishda animatsiya qayta boshlansin (key).
  3. Reduced-motion'da titrash bo'lmasin, faqat matn chiqsin.
  4. Xabar role="alert" bilan — Malika eshitsin.

Ishora: «Animatsiyani qayta boshlash: key» bo'limi. Urinishlar sonini state'da sanang va uni key qiling.

Yechim
jsx
import { useEffect, useState } from "react";

const css = `
  @keyframes titrash {
    25% { translate: -6px 0; }
    50% { translate: 6px 0; }
    75% { translate: -6px 0; }
  }
  .xato { color: #b91c1c; animation: titrash 300ms ease-in-out; }
  @media (prefers-reduced-motion: reduce) {
    .xato { animation: none; }
  }
`;

function ErrorMessage({ attempt }) {
  useEffect(() => {
    console.log("xabar chizildi:", attempt);
  }, []);
  return (
    <p className="xato" role="alert">
      Parol noto'g'ri ({attempt}-urinish)
    </p>
  );
}

export default function App() {
  const [attempts, setAttempts] = useState(0);
  return (
    <div>
      <style>{css}</style>
      <button onClick={() => setAttempts((a) => a + 1)}>
        Kirish
      </button>
      {attempts > 0 && (
        <ErrorMessage key={attempts} attempt={attempts} />
      )}
    </div>
  );
}

Konsolda:

text
xabar chizildi: 1
xabar chizildi: 2

Ekranda:

text
Kirish
Parol noto'g'ri (2-urinish)

Ikkinchi bosishda xabar yangidan yasaldi (konsolda ikkinchi yozuv) — titrash qayta boshlandi. role="alert" — yangi paydo bo'lgan xabar ekran o'quvchida darhol o'qiladi. Element har safar yangidan qo'yilgani uchun Malika har urinishda eshitadi. 25%, 50%, 75% dagi kadrlar; boshi va oxiri yozilmagan — element o'z joyidan boshlab, o'z joyida tugaydi.

3-mashq (qiyin): qayta ishlatiladigan useExitAnimation

«Avval animatsiya, keyin o'chirish» bo'limidagi mantiqni hook'ga chiqaring: const { mounted, leaving, close, onAnimationEnd } = useExitAnimation(open, setOpen, 300).

Talablar:

  1. close() — chiqishni boshlaydi; onAnimationEnd yoki ms lik zaxira taymer — qaysi biri oldin bo'lsa — setOpen(false) ni chaqiradi.
  2. onAnimationEnd faqat elementning o'z animatsiyasida ishlasin (e.target === e.currentTarget).
  3. Komponent o'chirilsa, taymer tozalansin.
  4. Bitta sahifada ikkita toast — ikkalasi ham hook'dan foydalansin.

Tekshirish: birinchi toastni yoping, ikkinchisi ochiq qolsin.

Ishora: taymerni useRef da saqlang, tozalashni bo'sh bog'liqlikli effektda qiling (useEffect II).

Yechim
jsx
import { useEffect, useRef, useState } from "react";

const css = `
  @keyframes chiqish { to { opacity: 0; translate: 0 8px; } }
  .chiqmoqda { animation: chiqish 150ms ease-in forwards; }
`;

function useExitAnimation(open, setOpen, ms) {
  const [leaving, setLeaving] = useState(false);
  const timer = useRef();
  useEffect(() => () => clearTimeout(timer.current), []);

  function finish() {
    clearTimeout(timer.current);
    setLeaving(false);
    setOpen(false);
  }
  function close() {
    setLeaving(true);
    timer.current = setTimeout(finish, ms);
  }
  function onAnimationEnd(e) {
    if (leaving && e.target === e.currentTarget) finish();
  }
  return { mounted: open, leaving, close, onAnimationEnd };
}

function Toast({ text, closeLabel }) {
  const [open, setOpen] = useState(true);
  const t = useExitAnimation(open, setOpen, 300);
  if (!t.mounted) return null;
  return (
    <p
      className={t.leaving ? "chiqmoqda" : undefined}
      onAnimationEnd={t.onAnimationEnd}
    >
      {text} <button onClick={t.close}>{closeLabel}</button>
    </p>
  );
}

export default function App() {
  return (
    <div>
      <style>{css}</style>
      <Toast text="Saqlandi" closeLabel="Birinchini yopish" />
      <Toast text="Nusxalandi" closeLabel="Ikkinchini yopish" />
    </div>
  );
}

Ekranda:

text
Nusxalandi Ikkinchini yopish

ekran("Saqlandi") — bosishdan keyingi oraliq holat: birinchi toast hali chiqmoqda, DOM'da. Keyin u o'chdi, ikkinchisi joyida. Har toastning o'z useExitAnimation i — o'z taymeri va o'z leaving holati. e.target === e.currentTarget — tugma ichida animatsiya bo'lsa ham (masalan hover effekti), toastni noto'g'ri paytda o'chirmaydi.

4-mashq: Vazifalar qadami

feat/css-animatsiya branch'ida: asos.css — dialog o'tishlari va ::backdrop reduced-motion qoidasi; vazifalar.css — --animate-kirish; ui/dropdown-menu.tsx va ui/tooltip.tsx — origin-(…) va data-[state=open]:motion-safe:animate-kirish. Keyin commit.

Yechim

Tekshiruv ro'yxati:

  • O'chirish oynasini oching va Esc bosing — oyna so'nib ketadi, keyin fokus «O'chirish» tugmasida.
  • DevTools → Rendering → prefers-reduced-motion: reduce — oyna va fon darhol yopiladi, menyu animatsiyasiz.
  • Menyuni oching va darhol Esc bosing — fokus menyu tugmasiga qaytadi (bandda qolmaydi).
  • npm run build va npm run preview da ham tekshiring — ::backdrop qoidasi production CSS'da bor.
  • npm test — node 203, Vitest 65. Commit: feat: dialog kirish/chiqish (@starting-style, allow-discrete), menyu animatsiyasi.

10. Real ishda

  • Dizayn tizimlari (shadcn, Radix Themes, korporativ kitlar) — kirish/chiqish asosan CSS va data-state bilan. tw-animate-css sinflarini shadcn kodida har kuni ko'rasiz.
  • Mobil veb — telefonda JavaScript sekinroq. CSS animatsiyasi kompozitorda ishlaydi va sahifa yuklanayotganda ham silliq qoladi.
  • Code review — "{open && …} + transition" va "forwards yo'q" — tez-tez topiladigan xatolar.
  • Intervyu savollari: "React'da chiqish animatsiyasini kutubxonasiz qanday qilasiz?", "@starting-style nima uchun kerak?", "allow-discrete qaysi muammoni hal qiladi?", "Radix yopilishni qanday kutadi?".

Xulosa

  • Holat o'zgarishi — className/data-*/aria-* + CSS transition. Yangi element uchun — @keyframes yoki @starting-style; qayta boshlash — key.
  • Chiqish: avval "chiqmoqda" holati va animatsiya, keyin o'chirish (onAnimationEnd + zaxira taymer, forwards).
  • <dialog>/popover: display va overlay — allow-discrete, kirish — @starting-style. JavaScript'siz.
  • Radix data-state="closed" dagi animatsiyani kutadi; kanonda menyu animatsiyasi faqat open da (kirish paytidagi Esc tuzog'i).
  • Tailwind: data-[state=open]:, motion-safe:; shadcn — tw-animate-css (animate-in, fade-in-0, zoom-in-95).
  • CSS yetadigan joyda — CSS (0 KB, kompozitor, CSP'ga mos); spring, gesture, layout — Motion.

Keyingi dars: Motion I: motion komponentlar va asosiy animatsiya — motion.div, initial/animate/transition, spring va keyframes massivi; vazifalar ga Motion qo'shiladi.

Manbalar

  • MDN: @starting-style, transition-behavior, overlay, animationend — developer.mozilla.org
  • MDN: Popover API (popovertarget) — developer.mozilla.org/docs/Web/API/Popover_API
  • Radix Primitives: Animation guide — radix-ui.com/primitives/docs/guides/animation
  • tw-animate-css — npm sahifasi; shadcn/ui: Tailwind v4 — ui.shadcn.com/docs/tailwind-v4
  • Tailwind CSS: motion-safe, animation — tailwindcss.com/docs
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
CSS animatsiyalari React'da: transition, keyframes, data-state va @starting-style bilan kirish-chiqish — IlmHamroh