GODRICH
#css-animation#button#loader#transition#scroll#ui#xu-huong-thiet-ke

9 hero section css animation — không cần JS

Hero section là màn hình lớn đập vào mắt đầu tiên khi bạn vừa vào một trang web, nơi đặt tiêu đề cỡ đại và nút bấm dựng nên ấn tượng ban đầu.

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

Chín mục không xếp theo độ phổ biến mà theo đúng thứ tự người vào trang gặp chúng: headline hút mắt ngay khi trang mở ra (01), chuyển động dịu ở lớp nền (02), nút CTA giữ chân ánh nhìn (03), mockup khoe sản phẩm (04) và huy hiệu bồi thêm niềm tin (05). Cuộn xuống chút nữa gặp dải logo đối tác trôi ngang (06) — hết phần chạy ngay sau khi tải. Ba mục còn lại (07~09) chỉ đáp lại khi bạn thật sự cuộn hoặc khi có gì đó vừa được lưu.

Số Tên Cái gì chuyển động Kỹ thuật chính
01 Headline hiện lần lượt opacity, translateY từng dòng @keyframes lệch mốc
02 Nền gradient trôi translate, rotate của lớp nền conic-gradient
03 CTA đập nhịp glow scale, box-shadow @keyframes lặp
04 Mockup bồng bềnh translateY, rotate rất nhỏ chỉ transform
05 Huy hiệu trượt vào translateX, opacity mốc dừng 30~65%
06 Dải marquee translateX(-50%) của track margin-right thay gap
07 Chữ lùi ra sau khi cuộn scale, opacity, translateY thay cho scroll-timeline
08 Chữ lộ dần qua mask clip-path: inset() scaleX gạch chân
09 Toast hiện lúc tải display, opacity, transform @starting-style

01Headline hiện lần lượt

hero section css design dùng mục này ở đâu

Ba dòng headline không bật lên cùng lúc mà từng dòng một xuất hiện trễ hơn dòng trước.

Tiêu đề vào lần lượt
Mở trong tab mới

01 · Tiêu đề vào lần lượt — Diện tích đo được 13.299% · khung hình chuyển động 10/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

@keyframesopacity · translateYstagger delay

Nguyên lý chạy thế nào

Mỗi dòng có @keyframes riêng với điểm bắt đầu lệch nhau ở 10%, 20%, 30%, nên chỉ hai giá trị opacity và translateY đã đủ dựng nên thứ tự.

.hero__line { opacity: 0; transform: translateY(24px); }

.hero__line--1 { animation: line-in-1 $duration $easing infinite; } // $easing: $ease-spring

@keyframes line-in-1 {
  0%   { opacity: 0; transform: translateY(24px); }
  10%  { opacity: 1; transform: translateY(0); }
  85%  { opacity: 1; transform: translateY(0); }
  100% { opacity: 0; transform: translateY(24px); }
}

02Nền gradient trôi

Cái gì đang chuyển động

Một lớp conic-gradient rộng hơn hẳn cái thẻ được trải vào ::before rồi nhốt lại bằng overflow: hidden, sau đó quay chậm chỉ bằng translate và rotate.

Nền gradient trôi chậm
Mở trong tab mới

02 · Nền gradient trôi chậm — Diện tích đo được 23.185% · khung hình chuyển động 21/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

::beforeconic-gradienttranslate · rotate

Cách tạo ra nó

Khác với cách dịch background-position, ở kiểu này dù thẻ đổi kích thước thì gradient cũng không phải tính lại.

.hero::before {
  content: '';
  position: absolute;
  inset: -60% -60%;
  background: conic-gradient(from 0deg, #ff477e, #7b2ff7, #4cc9f0, #ff477e);
  animation: drift $duration $easing infinite; // $easing: $ease-out
}
@keyframes drift {
  0%   { transform: translate(-6%, -4%) rotate(0deg); }
  50%  { transform: translate(6%, 5%) rotate(180deg); }
  100% { transform: translate(-6%, -4%) rotate(360deg); }
}

03CTA đập nhịp glow

Dùng ở đâu

Nút vừa scale vừa đổi box-shadow cùng lúc, phình ra thu vào như đang hít thở.

CTA đập nhịp phát sáng
Mở trong tab mới

03 · CTA đập nhịp phát sáng — Diện tích đo được 8.864% · khung hình chuyển động 16/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

box-shadowscale:hover · :focus-visible

Vì sao làm theo cách này

Tách riêng khỏi vòng lặp dành cho demo, :hover và :focus-visible thật cũng đáp lại bằng đúng màu đó, nên tắt vòng lặp đi thì bản thân cái nút vẫn sống.

.cta:hover { transform: translateY(-2px); }

.cta { animation: pulse $duration $easing infinite; } // $easing: $ease-pop

@keyframes pulse {
  0%   { transform: scale(1);     box-shadow: 0 0 0 0 rgba(255, 224, 102, .55); }
  50%  { transform: scale(1.045); box-shadow: 0 0 32px 8px rgba(255, 224, 102, .55); }
  100% { transform: scale(1);     box-shadow: 0 0 0 0 rgba(255, 224, 102, 0); }
}

04Mockup bồng bềnh

Bạn nhìn thấy gì

Cả tấm thẻ mockup có thanh trên đầu như cửa sổ trình duyệt cứ đảo qua lại giữa translateY và một góc rotate rất nhỏ, trông như đang lơ lửng.

Mockup lơ lửng
Mở trong tab mới

04 · Mockup lơ lửng — Diện tích đo được 16.142% · khung hình chuyển động 20/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

transform: translateYrotate@keyframes

Cốt lõi khi triển khai

Cả hai giá trị đều chỉ chạm vào transform nên bố cục không có lý do gì phải tính lại.

.mockup { animation: float $duration $easing infinite; } // $easing: $ease-spring

@keyframes float {
  0%   { transform: translateY(0) rotate(0deg); }
  50%  { transform: translateY(-16px) rotate(-1.2deg); }
  100% { transform: translateY(0) rotate(0deg); }
}

05Huy hiệu trượt vào

Đó là tình huống gì

Huy hiệu NEW trượt vào từ bên hông, nán lại một lúc rồi lại rút ra, cứ thế lặp đi lặp lại.

Badge trượt vào
Mở trong tab mới

05 · Badge trượt vào — Diện tích đo được 1.14% · khung hình chuyển động 9/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

transform: translateXopacitykeyframe hold

Mấu chốt nằm ở đâu

Khoảng 30% tới 65% được chèn sẵn một đoạn đứng yên, nên nó không chạy tới chạy lui liên tục mà chừa cho bạn chút thời gian đọc kịp.

.badge { animation: slide-in $duration $easing infinite; } // $easing: $ease-pop

@keyframes slide-in {
  0%   { opacity: 0; transform: translateX(40px); }
  30%  { opacity: 1; transform: translateX(0); }
  65%  { opacity: 1; transform: translateX(0); }
  100% { opacity: 0; transform: translateX(40px); }
}

06Dải marquee

Điều gì đang diễn ra

Dưới hero, một track ghép hai bộ viên thuốc logo đối tác trôi liền mạch bằng translateX(-50%).

Dải marquee chạy ngang
Mở trong tab mới

06 · Dải marquee chạy ngang — Diện tích đo được 7.556% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

translateX(-50%)margin-right gapdoubled track

Điểm cần nhớ

Khoảng hở đặt bằng margin-right trên từng viên chứ không phải gap của flex — dùng gap thì riêng chỗ nối giữa hai bộ bị hụt nửa khoảng, mỗi vòng lại giật một nhịp pixel.

.marquee__track {
  display: flex;
  width: max-content;
  animation: pill-scroll $duration $easing infinite; // $easing: linear
}
.marquee__pill { margin-right: 16px; flex-shrink: 0; } // margin-right thay cho gap

@keyframes pill-scroll {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

07Chữ lùi ra sau khi cuộn

Trên màn hình có gì

Ở một sản phẩm thật thì scroll-timeline buộc thẳng kích thước, độ trong và vị trí của headline vào tiến độ cuộn.

Chữ mờ lùi khi cuộn
Mở trong tab mới

07 · Chữ mờ lùi khi cuộn — Diện tích đo được 13.056% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

scale · opacitytranslateYtransform-origin: center top

Làm ra nó thế nào

Có điều mức hỗ trợ giữa các trình duyệt còn chênh nhau nhiều, nên demo này dựng lại đúng hình dáng đó bằng một vòng lặp @keyframes.

.hero { animation: recede $duration $easing infinite; transform-origin: center top; } // $easing: $ease-out

@keyframes recede {
  0%   { opacity: 1;   transform: scale(1)   translateY(0); }
  55%  { opacity: .15; transform: scale(.85) translateY(-40px); }
  100% { opacity: 1;   transform: scale(1)   translateY(0); }
}

08Chữ lộ dần qua mask

Trên màn hình trông thế nào

Headline lộ dần ra như bị một nhát quét từ trái sang phải kéo thân chữ theo.

Lộ chữ qua mặt nạ quét
Mở trong tab mới

08 · Lộ chữ qua mặt nạ quét — Diện tích đo được 2.719% · khung hình chuyển động 12/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

clip-path: inset()scaleX underlinesweep reveal

Đọc code như thế nào

Cách làm là bóp lề phải của clip-path: inset() từ 100% về 0%, và cùng nhịp đó gạch chân lớn dần bằng scaleX để cùng xuất hiện.

.headline__mask { clip-path: inset(0 100% 0 0); animation: reveal $duration $easing infinite; } // $easing: $ease-spring

@keyframes reveal {
  0%   { clip-path: inset(0 100% 0 0); }
  40%  { clip-path: inset(0 0% 0 0); }
  85%  { clip-path: inset(0 0% 0 0); }
  100% { clip-path: inset(0 100% 0 0); }
}

09@starting-style lúc tải trang

Khi nào dùng

Chính khoảnh khắc toast thông báo chuyển từ display: none sang hiện trên màn hình được dựng chỉ bằng @starting-style cùng transition-behavior: allow-discrete.

Vào màn hình bằng @starting-style
Mở trong tab mới

09 · Vào màn hình bằng @starting-style — Diện tích đo được 2.515% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

@starting-styletransition-behavior: allow-discretedisplay transition

Điểm chính của code

Không cần @keyframes, cũng không cần JS lo animation vào màn — đoạn JS 8 dòng trong demo chỉ để phát lại; ở sản phẩm thật, lúc một toast mới nổi lên thì thiếu JS nó vẫn chạy y nguyên.

@supports (transition-behavior: allow-discrete) {
  .toast {
    transition: display $duration allow-discrete, opacity $duration $easing, transform $duration $easing;
  }
  .toast.is-hidden {
    display: none;
    opacity: 0;
    transform: translateY($sp-3) scale(.92);
  }
  @starting-style {
    .toast { opacity: 0; transform: translateY($sp-3) scale(.92); }
  }
}

Dễ vỡ ở đâu — @starting-style bắt cả phần tử đã có sẵn

Vấn đề nằm ở đâu

Lúc mới dựng mục 09 cho phần animated hero section html css, mình đinh ninh @starting-style chỉ bắt những phần tử được thêm vào DOM về sau. Đo thực tế thì không phải: trang vừa mở ra, đoạn JS phát lại còn chưa chạy lần nào, toast đã kịp diễn trọn một lượt chuyển cảnh vào màn. @starting-style áp cho mọi khoảnh khắc "trông như vừa được render mới", và khung hình đầu tiên lúc trang vừa vẽ xong cũng nằm trong đó.

Tránh như thế nào

Vì vậy đoạn JS phát lại không phải là code "tạo ra" màn xuất hiện, mà gần với code "chiếu lại" thứ CSS đã tự làm xong một lần. Mật khẩu nén của file zip chứa chín file này là 5p7qvp7z, gõ đúng như vậy vào file bạn nhận được là bung ra. box-shadow ở mục 03 và clip-path ở mục 08 cũng mang cái bẫy cùng họ — khác với transform và opacity chỉ cần GPU ghép lớp, hai thứ này phải tô lại mỗi khung hình, càng tham chồng lớp thì máy cấu hình thấp càng lộ ra trước.

Khả năng tiếp cận

Còn lại gì khi bật reduced-motion

Trong môi trường đã bật reduced-motion, cả chín mục được chỉnh cho animation tắt hẳn. Mục 01 ở lại với ba dòng hiện đủ ngay từ đầu, mục 02, 04, 07 thành khung hình tĩnh không nhúc nhích, còn mục 05 và 08 dừng ở trạng thái cuối khi huy hiệu và headline đã lộ trọn.

Ngoài ra nên chú ý

Mục 09 chỉ tắt phần transition nên toast luôn giữ trạng thái nhìn thấy được — người khó chịu với chuyển động cũng không mất thông tin nào.

Hỏi đáp (FAQ)

Chỉ bằng CSS có dựng nổi animation cho hero section của landing page không?

Được, cả chín mục đều chuyển động thuần bằng CSS. Riêng mục 09 kèm theo đoạn JS 8 dòng cốt để phát lại demo, còn ở sản phẩm thật, khoảnh khắc một toast mới nổi lên thì bản thân đoạn JS đó là thừa.

@starting-style chạy được trên trình duyệt nào?

Chạy từ Chrome và Edge 117 trở lên. Tài liệu @starting-style trên MDN ghi rõ phạm vi hỗ trợ; trên trình duyệt chưa hỗ trợ, demo mục 09 rơi ra ngoài khối @supports và chỉ nằm yên ở trạng thái luôn hiện, còn cách bố trí phần tử thì vẫn nguyên vẹn.

Là animation theo cuộn, sao không cuộn nó vẫn chạy?

Mục 07 nếu là sản phẩm thật sẽ buộc thẳng vào tiến độ cuộn qua scroll-timeline, nhưng demo này phát lại đúng hình dáng đó bằng vòng lặp @keyframes theo thời gian, để chụp hình được và tương thích trình duyệt. Khi gắn vào thật, bạn xem tài liệu scroll-driven animations của MDN rồi đổi sang animation-timeline: view(). Bộ sưu tập CSS animation còn lại nằm ở trang tìm kiếm, cách blog này dựng demo thì ở trang giới thiệ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.