GODRICH

9 hiệu ứng css page transition animation không JS

Css page transition animation là hiệu ứng chuyển trang dựng bằng transform, opacity và clip-path thay vì đợi router vẽ lại giao diện.

Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip

Thứ tự chín hiệu ứng đi theo mức độ bạn phải biết trước trang sau nằm ở đâu. Từ 01 đến 04 chuyển trang mà không cần biết gì về nội dung hai bên — chỉ opacity, translate hoặc clip-path đổi giá trị. Từ 05 đến 07 mô phỏng cảm giác điều hướng có ngữ cảnh: ngăn xếp app, phóng to từ một card, đổi tab trong cùng một khung. 08 và 09 rời hẳn khỏi chuyện chuyển trang, để xử lý chuyển động khi bố cục tự sắp lại hoặc theme đổi màu. Lưới phía trên chỉ là bản xem nhanh chạy lặp; các đoạn code dưới mỗi mục đã đủ để bạn dán thẳng vào dự án, còn bản gốc không rút gọn nằm trong zip.

01Cross-fade mặc định

Trang cũ mờ dần rồi biến mất trong lúc trang mới chồng đúng lên vị trí đó và hiện dần ra, chỉ đổi opacity nên trình duyệt không phải tính lại layout ở giữa. Đây là kiểu chuyển trang mặc định, hợp với bài blog và điều hướng thông thường, nơi bạn không cần nhấn mạnh hướng đi.

300ms느리게 500ms살짝 확대
.page--a { animation: cross-a $duration $easing both; }
.page--b { animation: cross-b $duration $easing both; opacity: 0; }

@keyframes cross-a {
  0%, 5% { opacity: 1; }
  50% { opacity: 0; }
  95%, 100% { opacity: 1; }
}
@keyframes cross-b {
  0%, 5% { opacity: 0; }
  50% { opacity: 1; }
  95%, 100% { opacity: 0; }
}

02Trượt trái phải

Trang mới trượt vào từ mép phải trong khi trang cũ bị đẩy sang trái cùng lúc, tạo cảm giác bạn đang đi tới một bước tiếp theo chứ không phải nhảy trang đột ngột. Đảo ngược hướng translateX khi người dùng bấm nút lùi là đủ để mô phỏng cả tiến lẫn lùi trong form nhiều bước hoặc điều hướng mobile.

방향 반전250ms그림자 추가
.page--a { animation: slide-a $duration $easing both; }
.page--b { animation: slide-b $duration $easing both; transform: translateX(100%); }

@keyframes slide-a {
  0%, 5% { transform: translateX(0); }
  50% { transform: translateX(-100%); }
  95%, 100% { transform: translateX(0); }
}
@keyframes slide-b {
  0%, 5% { transform: translateX(100%); }
  50% { transform: translateX(0); }
  95%, 100% { transform: translateX(100%); }
}

03Thumbnail → hero dùng chung phần tử

Thumbnail đầu tiên trong danh sách phóng to liền mạch cho tới khi lấp gần hết khung, còn phần chữ chú thích chỉ hiện ra sau khi khối ảnh đã lớn gần tới đích. Kiểu này hợp với trang chi tiết sản phẩm hay portfolio, nơi người xem cần thấy rõ tấm ảnh họ vừa bấm là tấm nào.

view-transition-name400ms라운드 유지
.hero {
  position: absolute; left: clamp(12px, 5vw, 18px); top: 46px; width: 30%; height: 44px;
  border-radius: 8px; background: $subject-blue;
  transform-origin: top left;
  animation: hero-grow $duration $easing both;
}
.hero__caption {
  position: absolute; left: clamp(12px, 5vw, 18px); bottom: 14px; margin: 0;
  animation: caption-in $duration $easing both;
}

@keyframes hero-grow {
  0%, 40% { width: 30%; height: 44px; }
  70%, 90% { width: 88%; height: 108px; top: 12px; }
  100% { width: 30%; height: 44px; top: 46px; }
}
@keyframes caption-in {
  0%, 55% { opacity: 0; }
  75%, 90% { opacity: 1; }
}

04Lan tỏa hình tròn từ điểm nhấn

Một vòng tròn lan ra từ đúng vị trí nút bấm — ở đây là góc trên bên phải — cho tới khi phủ kín cả khung, nhờ clip-path circle() đổi bán kính chứ không phải một lớp phủ đổi kích thước khung. Đây là hiệu ứng quen thuộc cho nút chuyển dark mode và các nút đổi theme, vì mắt người dùng tự nhiên nhìn theo điểm họ vừa chạm vào.

clip-path circle600ms중앙 고정
.page--circle {
  clip-path: circle(0% at 88% 14%);
  animation: circle-grow $duration $easing both;
}

@keyframes circle-grow {
  0%, 5% { clip-path: circle(0% at 88% 14%); }
  50% { clip-path: circle(150% at 88% 14%); }
  95%, 100% { clip-path: circle(0% at 88% 14%); }
}

05Chuyển kiểu stack push của app

Màn hình mới trượt vào từ bên phải và chồng lên màn hình cũ, trong khi màn hình cũ hơi thu nhỏ lại và tối đi phía sau, tạo đúng cảm giác một app native vừa đẩy thêm một trang vào ngăn xếp điều hướng. Kiểu này hợp với web app trên mobile và luồng chuyển sang màn hình cài đặt, nơi người dùng cần biết mình vừa đi sâu thêm một bước chứ chưa rời màn hình gốc.

280ms뒤 화면 어둡게엣지 스와이프
.page--a { animation: push-back $duration $easing both; transform-origin: left center; }
.page--b { animation: push-front $duration $easing both; transform: translateX(100%); }
.dim { animation: push-dim $duration $easing both; }

@keyframes push-back {
  0%, 5% { transform: scale(1); }
  50% { transform: scale(.94); }
  95%, 100% { transform: scale(1); }
}
@keyframes push-front {
  0%, 5% { transform: translateX(100%); }
  50% { transform: translateX(0); }
  95%, 100% { transform: translateX(100%); }
}

06Card phóng to thành chi tiết

Card vừa được nhấn phóng to bằng transform: scale cho tới khi lấp gần hết khung, rồi lớp chi tiết hiện ra đúng lúc card đã đủ lớn để che khuất phần nội dung cũ bên dưới. Cách này hợp với gallery ảnh và danh sách card tin tức, nơi người đọc muốn thấy đúng tấm mình chọn phóng to trước khi đọc nội dung đầy đủ.

350ms모서리 반경 0으로배경 어둡게
.cell--focus {
  position: relative;
  animation: zoom-card $duration $easing both;
}
.detail {
  position: absolute; inset: 0; background: $color; color: #fff;
  opacity: 0;
  animation: zoom-detail $duration $easing both;
}

@keyframes zoom-card {
  0%, 40% { transform: scale(1); }
  70% { transform: scale(4); }
  100% { transform: scale(1); }
}
@keyframes zoom-detail {
  0%, 55% { opacity: 0; }
  75%, 90% { opacity: 1; }
}

07Cross-fade nội dung tab

Nội dung tab cũ mờ dần và trôi lên trên tám pixel trong lúc nội dung tab mới mờ dần hiện ra từ phía dưới, để mắt người xem luôn có một điểm chuyển động để bám theo thay vì thấy nội dung biến mất đột ngột. Kiểu chuyển này hợp với tab cài đặt hay các panel trong dashboard, nơi việc đổi tab xảy ra liên tục và không nên gây chớp màn hình.

200ms8px 오프셋깜빡임 없이
.page--a { animation: tab-a $duration $easing both; }
.page--b { animation: tab-b $duration $easing both; opacity: 0; transform: translateY(8px); }

@keyframes tab-a {
  0%, 5% { opacity: 1; transform: translateY(0); }
  50% { opacity: 0; transform: translateY(-8px); }
  95%, 100% { opacity: 1; transform: translateY(0); }
}
@keyframes tab-b {
  0%, 5% { opacity: 0; transform: translateY(8px); }
  50% { opacity: 1; transform: translateY(0); }
  95%, 100% { opacity: 0; transform: translateY(8px); }
}

08Animation di chuyển khi sắp xếp lại

Khi thứ hạng đổi chỗ, từng dòng chỉ trượt theo trục dọc đúng khoảng cách giữa vị trí cũ và vị trí mới bằng translateY, theo đúng tinh thần kỹ thuật FLIP — đo trước, rồi chỉ animate phần chênh lệch. Kiểu này hợp với bảng xếp hạng hay danh sách sắp theo giá, nơi người xem cần thấy một mục vừa vượt lên chứ không phải cả danh sách bị xáo lại từ đầu.

FLIP 기법300ms순위 뱃지 강조
.row--1 { animation: swap-down $duration $easing both; }
.row--2 { animation: swap-up $duration $easing both; }

@keyframes swap-down {
  0%, 5% { transform: translateY(0); }
  50% { transform: translateY(34px); }
  95%, 100% { transform: translateY(0); }
}
@keyframes swap-up {
  0%, 5% { transform: translateY(0); }
  50% { transform: translateY(-34px); }
  95%, 100% { transform: translateY(0); }
}

09Wipe chuyển sang dark mode

Một hình nêm tối lan chéo từ góc màn hình bằng clip-path polygon() đang đổi toạ độ, phủ dần lên trang sáng cho tới khi cả khung chuyển hẳn sang màu tối. Kiểu wipe theo đường chéo này hợp với nút chuyển dark mode và màn hình cài đặt, tạo cảm giác một lớp màu quét qua thay vì màn hình chỉ đổi màu tức thì.

대각선450ms가장자리부터
.page--wipe {
  clip-path: polygon(0 0, 0 0, 0 0);
  animation: dark-wipe $duration $easing both;
}

@keyframes dark-wipe {
  0%, 35% { clip-path: polygon(0 0, 0 0, 0 0); }
  75%, 90% { clip-path: polygon(0 0, 200% 0, 0 200%); }
  100% { clip-path: polygon(0 0, 0 0, 0 0); }
}

Chỗ dễ hỏng ở đâu? — cái bẫy

Trong chín hiệu ứng này, chỉ mục 03 chạm gần tới view-transition-name — thuộc tính gắn với View Transitions API thật của trình duyệt cho điều hướng same-document — còn tám hiệu ứng còn lại chỉ dùng transform, opacity và clip-path nên chạy được ở bất cứ đâu CSS animation chạy được. Cái bẫy nằm ở đó: kiểu same-document của View Transitions API mới ổn định từ Chrome 111, từ Safari 18, còn Firefox phải tới bản 144 mới hỗ trợ, đúng như bảng hỗ trợ trong tài liệu View Transitions API của MDN. Nếu bạn định gọi document.startViewTransition() thật cho một dự án nhiều trang, hãy kiểm tra sự tồn tại của nó trước rồi để mục 01 hay mục 02 làm phương án lùi, vì cả hai không đụng tới API đó chút nào.

Cái bẫy thứ hai dễ bỏ qua hơn: những con số ở các mốc phần trăm trong @keyframes hay trong clip-path polygon() ở các file này đã được canh khớp với đúng khung 480×300 của demo, nên nếu bạn dán thẳng chúng vào một trang có kích thước khác, hình tròn có thể không phủ hết khung hoặc hình nêm dừng lại giữa chừng — mở file vanilla trong zip ra chỉnh lại tọa độ cho đúng khung của bạn là cách chắc nhất, và mật khẩu để mở file zip đó là amqzkrw2.

Cái bẫy thứ ba nằm ngay ở lưới phía trên bài: mỗi ô đặt trong một khung iframe cố định 480×300, không có chỗ cho một cú điều hướng trang thật diễn ra ở đó, nên mỗi ô tự đổi qua lại giữa hai "trang" mỗi hai giây bằng một vòng lặp .is-demo. Bạn chỉ xem chuyển động lặp ở lưới đó thôi, không bấm gì cả; muốn thấy đúng cú chuyển trang mà mục 05 hay mục 06 tạo ra khi có một lần nhấn thật, hãy mở riêng file vanilla trong zip.

Khả năng tiếp cận

Cả chín demo đều trả lời prefers-reduced-motion, nhưng cách xử lý khác nhau tuỳ vào việc mỗi hiệu ứng coi trạng thái nào là "kết quả cuối". Từ 01, 02 và 07 tắt animation rồi giữ nguyên trạng thái cuối của trang B — mờ hẳn, ở đúng vị trí — mà không còn khung hình trung gian nào lướt qua mắt. Mục 03 khoá caption ở opacity 0 để không có chữ nào bay lên giữa chừng, còn mục 04 và 09 đưa clip-path (circle hoặc polygon) về đúng điểm 0% ban đầu thay vì đứng giữa hình tròn hay hình nêm dở dang. Mục 05 giữ nguyên bản "trang B đã đẩy vào" với lớp dim tắt hẳn, mục 06 giữ detail ở opacity 0 thay vì phóng to dở dang, còn mục 08 đặt cả hai dòng thứ hạng về translateY(0) như thể chưa từng đổi chỗ. Cách media query này hoạt động và được trình duyệt nào hỗ trợ, trang tham chiếu prefers-reduced-motion trên MDN liệt kê đầy đủ.

@media (prefers-reduced-motion: reduce) {
  .page--a, .page--b { animation: none !important; }
  .hero, .hero__caption { animation: none !important; }
  .page--circle { animation: none !important; clip-path: circle(0% at 88% 14%); }
  .row--1, .row--2 { animation: none !important; transform: translateY(0); }
}

Xem thêm cách phối transform và clip-path khác trong chuyên mục CSS, còn trang giới thiệu nói rõ site này thực chất cung cấp gì.

FAQ

Css page transition animation có cần JavaScript không?

Không. Cả chín hiệu ứng ở đây chỉ dùng transform, opacity và clip-path bên trong keyframes CSS, không có một dòng addEventListener nào điều khiển việc chuyển trang thật. Thư mục react/ trong zip chỉ bọc cùng một đoạn SCSS vào component, phần chuyển động vẫn nằm trọn trong CSS.

Nên chọn transform hay clip-path cho page transition?

Chọn transform và opacity khi bạn cần một chuyển động mượt nhất có thể, vì trình duyệt xử lý được trên compositor mà không phải tính lại layout — đó là cách 01, 02, 05, 06, 07 và 08 làm. Chọn clip-path khi hình dạng của vùng che mới là điểm nhấn, như vòng tròn ở mục 04 hay hình nêm chéo ở mục 09, vì animate trực tiếp width hay height sẽ khiến hình giật ở mỗi khung hình.

Có dùng css page transition animation này chung với React hoặc Next.js được không?

Được. Chín hiệu ứng đều là class CSS thuần và keyframes, nên bạn gắn class vào đúng thời điểm chuyển route bằng state của React hoặc hook điều hướng của thư viện router, còn phần chuyển động vẫn để CSS lo hoàn toàn. Thư mục react/ trong zip có sẵn một component cho mỗi hiệu ứng để bạn không phải viết lại các khối SCSS từ đầu.

Nhập mật khẩu file nén

Mật khẩu nằm ngay trong bài viết này. Đọc tới đó bạn sẽ thấy.