Có một kiểu giao diện nhìn trong video thì rất “đã”: card nảy lên, bo góc đổi liên tục rồi bung thành trang chi tiết. Nhưng khi mình bấm thử nhiều lần, mắt lại phải đuổi theo cái card thay vì đọc nội dung. Material 3 motion physics không cứu được một animation thiếu mục đích; spring chỉ làm vấn đề trông mượt hơn thôi.

Mình dựng một prototype web nhỏ để kiểm tra đúng chỗ này. Cùng một card, cùng nội dung và cùng nút bấm, mình so ba phiên bản: easing cố định, spring có overshoot nhỏ và reduced-motion không dịch chuyển. Đây là bản diễn giải nguyên tắc Material trên web, không phải component Material chính thức. Mình chạy trên Windows 11, Microsoft Edge 151.0.4129.72 và kiểm tra bằng keyboard, effective viewport 720 px, prefers-reduced-motion cùng CPU throttle 4×.

Một animation mượt vẫn có thể làm UI khó dùng

Vấn đề của bản đầu không phải giật hình. Nó chạy êm, nhưng card vừa trượt lên, vừa scale, vừa đổi bán kính góc trong khi nội dung mới cũng mở ra. Ba tín hiệu cùng tranh nhau nói “nhìn tôi đi”. Nếu màn hình còn có badge đổi màu, icon xoay và thanh điều hướng chuyển tab thì tới công chuyện: người dùng không biết đâu là thay đổi chính.

Google mô tả Material 3 Expressive bằng năm đòn bẩy: color, shape, size, motion và containment. Nghiên cứu được công bố nói hệ thống đã đi qua 46 nghiên cứu với hơn 18.000 người tham gia. Con số đó cho thấy đây không phải một bộ hiệu ứng bốc đồng, nhưng cũng không có nghĩa mọi sản phẩm dùng spring sẽ tự nhiên tốt hơn. Nghiên cứu của Google là dữ kiện; chọn card nào đáng chuyển động vẫn là việc của đội sản phẩm.

Motion physics khác easing cũ ở đâu?

Easing cố định trả lời câu hỏi “sau bao nhiêu mili giây thì kết thúc?”. Ví dụ của mình dùng 280 ms với một đường cong tăng tốc rồi hãm. Mọi lần mở card có cùng lịch trình, dù khoảng cách hay trạng thái trước đó ra sao. Cách này dễ đọc, dễ bàn giao và rất hợp với những tương tác lặp nhanh.

Spring lại mô tả cảm giác bằng các đặc tính gần với khối lượng, độ cứng và độ giảm chấn. Nó có thể tiến tới đích, vượt nhẹ rồi ổn định. Điểm hay là phản ứng có vẻ liên tục khi người dùng ngắt giữa chừng; điểm dở là designer rất dễ viết một câu mơ hồ kiểu “nảy nhẹ cho vui”. Developer sau đó tự đoán thông số, và ba nền tảng cho ra ba nhịp khác nhau.

Trong prototype, mình không cố sao chép token Android sang CSS. Tài liệu Android xác nhận Jetpack Compose có implementation Material 3 Expressive, còn web demo này chỉ dùng CSS linear() để xấp xỉ một đường spring 520 ms. Chỗ phân biệt này quan trọng: nguyên tắc thiết kế có thể chia sẻ, implementation thì phải đo lại trên nền tảng thật.

Năm đòn bẩy expressive phải có thứ bậc

Mình cho card chính dùng shape, size và motion để nối trạng thái tóm tắt với detail. Color chỉ đổi rất nhẹ, containment vẫn giữ nguyên, còn focus ring đứng yên. Như vậy chỉ có một “hero action” trên màn hình. Các button phụ, checkbox và thông báo thành công không cần bắt chước chuyển động đó.

Animation không thay cho semantic. Nút vẫn có aria-expanded, focus vẫn thấy bằng keyboard và nội dung vẫn đúng thứ tự DOM. Nếu tắt motion mà không hiểu card mở hay đóng, lỗi nằm ở state và label. Giống khi mình thử dialog CSS mới, hiệu ứng chỉ nối trạng thái, không phải điều kiện để hiểu nó.

Mình dựng ba phiên bản của cùng một interaction

Easing, spring và fallback dùng chung markup

Bản baseline dịch card 8 px và scale lên 1,02 trong 280 ms. Bản spring dịch 12 px, scale 1,035 và có một overshoot nhỏ trong 520 ms; mình chỉ dành nó cho card chính. Bản reduced không dịch, không scale, chỉ hiện nội dung trong 120 ms và thêm viền xanh để báo trạng thái mới. Cả ba dùng cùng button, cùng copy và cùng vùng detail.

Baseline dứt khoát; spring nối card với detail; reduced bình tĩnh nhưng không “đứng hình”. Mình thích spring khi mở một lần, còn thao tác lặp nhanh thì baseline dễ chịu hơn.

Ba card prototype so sánh easing cố định, spring có chủ đích và reduced motion
Cùng markup và state, ba phiên bản chỉ khác motion token; bản reduced giữ viền báo trạng thái nhưng không dịch hay scale.

Đây không phải Material implementation chính thức cho web

Compose có component, theme và API riêng; web ở đây không có sự tương đương một-một. Chuyển nguyên thông số Android sang CSS còn bỏ qua input, refresh rate, layout engine và cách browser xử lý interruption. Vì vậy mình ghi token theo mục đích trước, rồi mới map sang CSS. Anh em có app Android nên kiểm tra lại bằng component Material thật thay vì lấy con số 520 ms của bài này làm chuẩn.

Token nào designer phải bàn giao?

Một motion spec dùng được cần nhiều hơn duration. Với mỗi trigger, mình ghi mục đích, điểm bắt đầu, khoảng dịch chuyển, transform origin, đường motion, cách ngắt, exit và fallback. Token motion.hero.spring chỉ được dùng khi cần giữ mối liên hệ card-to-detail; nó không phải nhãn chung cho mọi thứ “đẹp”.

Bảng dưới còn giữ focus ring thành token riêng. Khi card scale, outline không được bị cắt bởi container. Đây là chi tiết nhỏ nhưng rất dễ rơi mất lúc bàn giao từ design system sang code. Kinh nghiệm từ lúc mình kiểm tra Polaris Web Components cũng vậy: tên token chỉ có giá trị khi có consumer, state và test tương ứng.

Bảng motion inventory gồm trigger, mục đích, token, thuộc tính, fallback và bốn kiểm tra
Motion inventory mình dùng để nối ý định thiết kế với consumer trong code và fallback cần kiểm tra.
TriggerMục đíchMotionFallbackTiêu chí pass
Mở card chínhNối card với detailSpring 520 ms, dịch 12 pxOpacity 120 ms + viềnKhông mất điểm neo, state đọc được
Đóng detailTrả về danh sáchEasing ngắn, không overshootInstant stateFocus trở về nút mở
Focus buttonBáo vị trí keyboardKhông transformGiữ outlineOutline không bị clip

Reduced motion không có nghĩa xóa sạch tín hiệu

WCAG 2.3.3 nói motion animation do tương tác kích hoạt phải có thể tắt nếu nó không thiết yếu. Đây là tiêu chí Level AAA, nhưng tác động thực tế không nên đợi tới lúc audit mới xử lý: chuyển động thừa có thể gây chóng mặt, đau đầu hoặc buồn nôn cho người nhạy cảm tiền đình.

MDN cũng nhắc giá trị media query là reduce, không phải none. Người dùng đang yêu cầu giảm chuyển động không thiết yếu, chứ không bảo giao diện im lặng. Trong fallback của mình, opacity ngắn và viền trạng thái vẫn truyền thông tin; translate, scale và đổi shape bị loại. Code cốt lõi chỉ cần rõ như sau:

@media (prefers-reduced-motion: reduce) {
  .card,
  .detail {
    transition: opacity 120ms linear;
    transform: none !important;
  }
}

Đừng dùng quy tắc toàn cục * { animation: none !important; } rồi coi như xong. Cách đó có thể làm mất feedback hoặc phá component phụ thuộc transitionend. Hãy audit từng motion theo mục đích, tương tự cách mình kiểm tra zoom và fallback trong bài CSS text-fit cho tiêu đề tiếng Việt.

Mình kiểm tra motion bằng bốn tình huống

Đầu tiên, mình Tab tới nút và nhấn Enter. Card mở, aria-expanded đổi thành true, nút vẫn là active element và focus ring đo được 4 px. Tiếp theo, mình dùng effective viewport 720 px để đại diện cho trang 1440 px ở zoom 200%: ba card xếp một cột, document không tràn ngang; riêng bảng nằm trong wrapper cuộn.

Khi emulation chuyển sang prefers-reduced-motion: reduce, computed style của card spring trở thành transform: none, chỉ còn transition opacity 0,12 giây. Cuối cùng mình bật CPU throttle 4×, click card spring và đo lần chạy local này: state đổi ngay, frame đầu được callback sau 7,5 ms. Đây không phải benchmark cho mọi máy; nó chỉ chứng minh handler ngắn và việc hiểu state không phải chờ animation kết thúc.

Mình cũng không claim đã test screen reader hay thiết bị Android. Máy thử là Edge trên Windows và prototype không phải app production. Nếu component đi vào form thật, nên lặp lại với nội dung dài, localization, screen reader và thiết bị cảm ứng. Bài CSS field-sizing là một ví dụ khác cho việc animation/layout đẹp ở demo vẫn phải chịu bài test nội dung thật.

Khi nào mình sẽ không dùng spring?

Mình sẽ bỏ spring ở luồng nhập liệu lặp nhanh, thao tác nguy hiểm, bảng dữ liệu dày, loading kéo dài và action cần phản hồi tức thì. Với delete/confirm, sự chắc chắn quan trọng hơn cảm giác đàn hồi. Với danh sách phải mở liên tục, 520 ms dù mượt vẫn trở thành thuế chờ.

Chốt lại, Material 3 motion physics đáng học ở cách nối trạng thái và tạo thứ bậc, không phải hiệu ứng nảy. Hãy chọn motion có purpose, viết token có fallback và test với preference thật. Giới hạn vẫn rõ: CSS chỉ xấp xỉ spring, chưa có screen reader và chưa chạy trên thiết bị low-end thật.

Nguồn đã đối chiếu ngày 10/08/2026: