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

9 bento grid css — hiệu ứng hover dán một dòng

Bạn từng thấy trang portfolio ghép các ô vuông to nhỏ liền nhau như một tấm collage ảnh chưa?

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à đi theo thứ tự mắt người quét qua một tấm lưới. Trước hết là tín hiệu nhẹ báo rằng cả ô đều phản ứng (01~02), tiếp đến là chuyển động ở cấp nội dung khi ảnh và icon bên trong đáp lại (03~05), rồi tới khoảnh khắc dữ liệu dạng số thay đổi (06) và một cú thử táo bạo trông như đang sửa chính bố cục (07), khép lại bằng lúc lưới lọt vào màn hình (08) và ánh sáng trải khắp cả lưới (09).

Số Tên Cái gì chuyển động JS
01 Phóng to thở cùng bóng đổ transform: scale, box-shadow không
02 Ô viền chạy tia sáng vòng conic-gradient xoay không
03 Ô ảnh zoom trong khung lớp media scale, khung đứng yên không
04 Ô icon bồng bềnh translateY của icon không
05 Ô lộ gradient khi hover opacity màn phủ, translateY tiêu đề không
06 Ô đếm số thống kê --num kiểu integer chạy tới 87 không
07 Ô nở rộng như đổi grid span transform: scale, z-index không
08 Ô hiện lần lượt theo cuộn transition-delay lệch nhau vài dòng
09 Glow lan sang ô kế bên --mx --my chia chung cả lưới vài dòng

01Phóng to thở cùng bóng đổ

bento grid design dùng mục này ở đâu

Khi con trỏ chạm vào, cả ô nở ra một chút và bóng đổ sâu thêm, phản ứng hệt như một nhịp thở.

Thẻ phồng nhẹ như thở
Mở trong tab mới

01 · Thẻ phồng nhẹ như thở — Diện tích đo được 2.481% · 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

scalebox-shadow300ms

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

Chỉ transform: scale(1.04) và box-shadow cùng transition nên bố cục không hề rung.

.bs-focal {
  transition: transform 300ms $ease-spring, box-shadow 300ms $ease-spring;
  box-shadow: $shadow-press;
}
.bs-focal:hover {
  transform: scale(1.04);
  box-shadow: $shadow-raised;
}

02Ô viền chạy tia sáng

Đó là màn hình nào

Rê chuột lên ô bạn muốn nhấn mạnh, một tia sáng mảnh chạy trọn một vòng dọc theo đường viền.

Tile viền tia sáng chạy vòng
Mở trong tab mới

02 · Tile viền tia sáng chạy vòng — Diện tích đo được 5.527% · 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

conic-gradientmask1.6s

Cốt lõi khi triển khai

Hình vòng được khoét ra từ conic-gradient bằng mask-composite: exclude, còn phần quay chỉ xoay transform của đúng lớp đó.

.bb-focal::before {
  content: "";
  position: absolute;
  inset: 0;
  padding: $sp-1;
  background: conic-gradient(from 0deg, transparent 0 70%, $color 85%, transparent 100%);
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  opacity: 0;
}
.bb-focal:hover::before {
  opacity: 1;
  animation: bb-spin 1.6s linear infinite;
}

03Ô ảnh zoom trong khung

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

Ở ô có thumbnail, con trỏ chạm vào thì đường viền vẫn đứng yên, riêng lớp ảnh bên trong phóng to.

Zoom ảnh trong tile
Mở trong tab mới

03 · Zoom ảnh trong tile — Diện tích đo được 2.692% · 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

scaleoverflow: hidden400ms

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

overflow: hidden giữ chặt cái khung, chỉ lớp media bên trong nó dịch chuyển bằng transform: scale.

.iz-tile--a { overflow: hidden; }
.iz-tile__media {
  position: absolute;
  inset: 0;
  transition: transform 600ms linear;
}
.iz-focal:hover .iz-tile__media {
  transform: scale(1.15);
}

04Ô icon bồng bềnh

Dùng ở đâu

Icon nhỏ trong ô giới thiệu tính năng bình thường nằm im, phải có con trỏ tới thì mới bắt đầu nhấp nhô lên xuống.

Icon bồng bềnh trong tile
Mở trong tab mới

04 · Icon bồng bềnh trong tile — Diện tích đo được 0.461% · 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

translateY@keyframes1.4s

Điểm cần nhớ

Suốt quãng thời gian không hover thì nó đứng yên hoàn toàn, mắt bạn không bị phân tán.

.if-focal:hover .if-icon {
  animation: if-bob 1.4s $ease-out infinite;
}
@keyframes if-bob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-6px); }
}

05Ô lộ gradient khi hover

Bạn nhìn thấy gì

Ô vốn chỉ hiện mỗi tấm ảnh, đưa con trỏ vào thì màn gradient phía dưới đậm dần lên và tiêu đề nhích nhẹ lên trên.

Tile lộ dần lớp gradient
Mở trong tab mới

05 · Tile lộ dần lớp gradient — Diện tích đo được 13.982% · 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

opacitytranslateY350ms

Làm ra nó thế nào

Màn phủ chỉ đổi opacity, tiêu đề chỉ đổi translateY, còn tấm ảnh không bị đụng tới.

.gr-tile__scrim {
  opacity: 0;
  transition: opacity 350ms $ease-out;
}
.gr-tile__label {
  transform: translateY($sp-2);
  transition: transform 350ms $ease-out;
}
.gr-focal:hover .gr-tile__scrim { opacity: 1; }
.gr-focal:hover .gr-tile__label { transform: translateY(0); }

06Ô đếm số thống kê

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

Ở ô khoe thành tích bằng con số, con trỏ vừa chạm là số chạy từ 0 lên tới giá trị đích.

Tile đếm số liệu
Mở trong tab mới

06 · Tile đếm số liệu — Diện tích đo được 0.54% · khung hình chuyển động 14/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

@propertycounter()1.2s

Đọc code như thế nào

Không cần JavaScript: đăng ký custom property kiểu integer bằng @property rồi để keyframes nội suy thẳng con số đó.

@property --num {
  syntax: '<integer>';
  inherits: true;
  initial-value: 0;
}
.sc-stat__value::after {
  counter-reset: num var(--num);
  content: counter(num);
}
@keyframes sc-countup { to { --num: 87; } }
.sc-focal:hover .sc-stat__value {
  animation: sc-countup 1.2s $ease-out forwards;
}

07Ô nở rộng như đổi grid span

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

Chạm con trỏ vào một ô, trông như nó lớn lên và phủ cả ô bên cạnh, nhưng thật ra giá trị span của grid-column hay grid-row không đổi.

Giả mở rộng span khi hover
Mở trong tab mới

07 · Giả mở rộng span khi hover — Diện tích đo được 13.703% · 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

scalez-indextransform-origin: top left

Điểm chính của code

Bản thân giá trị đó là số rời rạc, không transition được, nên chỗ này giả lập việc nở rộng bằng transform: scale cộng z-index để chồng lên ô kế bên.

.he-focal {
  transition: transform 600ms $ease-spring, box-shadow 600ms $ease-spring;
  box-shadow: $shadow-press;
}
.he-focal:hover {
  transform: scale(1.35);
  box-shadow: $shadow-raised;
  z-index: 5;
}

08Ô hiện lần lượt theo cuộn

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

Lưới vừa lọt vào khung nhìn là các ô lần lượt nổi lên từng cái một.

Tile hiện lần lượt theo scroll
Mở trong tab mới

08 · Tile hiện lần lượt theo scroll — Diện tích đo được 36.644% · khung hình chuyển động 13/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

IntersectionObservertransition-delayJS 8줄

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

IntersectionObserver gắn class .in cho từng ô, mỗi ô đặt transition-delay khác nhau nên cùng một chuyển động lặp lại lệch pha theo thời gian.

var io = new IntersectionObserver(function (entries) {
  entries.forEach(function (e) {
    if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); }
  });
}, { threshold: 0.3 });
tiles.forEach(function (t) { io.observe(t); });
.ss-tile { transition: opacity 400ms $ease-out, transform 400ms $ease-out; }
.ss-tile.in { opacity: 1; transform: translateY(0); }
.ss-tile:nth-child(2).in { transition-delay: 160ms; }
.ss-tile:nth-child(3).in { transition-delay: 320ms; }
.ss-tile:nth-child(4).in { transition-delay: 480ms; }

09Glow lan sang ô kế bên

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

Di chuyển con trỏ trên lưới, ánh sáng dịu không chỉ tỏa ở ô đang chỉ vào mà loang sang cả các ô hàng xóm.

Ô kề bên phát sáng
Mở trong tab mới

09 · Ô kề bên phát sáng — Diện tích đo được 36.649% · 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

:has()radial-gradientJS 8줄

Cách tạo ra nó

Tọa độ được cất vào hai custom property --mx --my cho mọi ô dùng chung, còn :has() chỉ xét xem trong lưới có ô nào đang được hover hay không để bật đèn.

.sl-tile::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(140px circle at var(--mx) var(--my), rgba($color, .6), transparent 70%);
  opacity: 0;
}
.sl-grid:has(.sl-tile:hover) .sl-tile::before { opacity: 1; }
grid.addEventListener('pointermove', function (e) {
  var r = grid.getBoundingClientRect();
  var x = ((e.clientX - r.left) / r.width * 100) + '%';
  var y = ((e.clientY - r.top) / r.height * 100) + '%';
  grid.style.setProperty('--mx', x);
  grid.style.setProperty('--my', y);
});

bento grid layout css dễ vỡ ở đâu

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

Trong chín mục thì 07 là chỗ vấp nhiều lần nhất. Bạn sẽ rất hay muốn đổi luôn kích thước ô khi hover theo kiểu grid-column: span 2, nhưng span của grid là giá trị rời rạc, trình duyệt không dựng nổi khung hình trung gian nên transition không hề chạy — nó chỉ nhảy phắt một cái như công tắc bật tắt. Vì vậy cách bố trí ô thật vẫn giữ nguyên, còn transform: scale cộng z-index lo phần chồng lên ô bên cạnh.

Tránh như thế nào

Selector :has() ở mục 09 và counter @property ở mục 06 cũng là tính năng mới vào gần đây: Chrome, Edge, Safari bản mới và Firefox chạy tốt, còn trình duyệt cũ chỉ hiện trạng thái cuối đứng im. Mục 08 vốn có animation-timeline: view() là tính năng CSS gốc chạy animation thuần theo vị trí cuộn, nhưng mức hỗ trợ và kết quả render lệch nhau giữa các trình duyệt nên demo này không dùng, thay vào đó là IntersectionObserver chạy được ở mọi nơi. Mật khẩu cần khi giải nén file zip nằm ngay ở chỗ 6s9wc2fb dưới dạng chữ thường trong bài, bạn đọc chậm đoạn này là thấy.

Khả năng tiếp cận của chín mục

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

Bật chế độ giảm chuyển động trong hệ thống thì cả chín mục đều tắt chuyển động bằng animation: none !important dưới prefers-reduced-motion: reduce. Các phản ứng thuần trang trí như 01, 02, 03, 05, 07, 09 chỉ cần đứng im là đủ; riêng phần đếm số ở 06 và thứ tự hiện ra ở 08 lại chở chính thông tin trong chuyển động, nên thay vì tắt animation, --num cùng opacity và transform được ghim thẳng vào giá trị cuối để con số và nội dung hiện đủ ngay từ đầu dù không nhúc nhích.

Đọc tiếp gì

Nếu bạn cần kiểu lưới ô này ở nhiều biến thể khác, cả danh sách nằm trong chuyên mục CSS animation, còn trang giới thiệu nói rõ blog này đo và dựng demo theo cách nào.

Hỏi đáp (FAQ)

bento grid là gì và có bắt buộc dùng CSS grid không?

Bento grid là kiểu lưới ghép các ô kích thước khác nhau như hộp cơm bento, và đúng là phải có display: grid cùng grid-template-columns mới sắp được các ô lệch cỡ như vậy. Chín demo trong bài dùng lưới mini 3~4 ô chỉ để khoe phản ứng hover, còn trang thật thì bạn chỉ cần tăng số ô và giá trị span lên.

Máy cảm ứng không có chuột thì hover đâu chạy?

Đúng vậy. Thiết bị cảm ứng thường không bắt :hover, hoặc chạm một cái là dính luôn không gỡ, nên các phản ứng thuần thị giác như 01, 03, 05 được thiết kế để bỏ qua trên cảm ứng mà không mất thông tin; riêng mục quan trọng về con số như 06 thì nên đổi trigger sang thời điểm lọt vào khung nhìn.

Code vanilla và react trong file zip có khác nhau không?

Cách chạy thì giống nhau. Bản vanilla đặt biến SCSS ở đầu file, bản React nhận đúng những giá trị đó qua props của component rồi chuyển thành CSS custom property, nên màn hình kết quả y hệt.

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.