Hướng dẫn timeline horizontal css

Vietnamese (Tiếng Việt) translation by Dai Phong (you can also view the original English article)

Trong bài viết trước, tôi đã hướng dẫn cho bạn cách xây dựng một responsive vertical timeline từ đầu. Hôm nay, tôi sẽ trình bày quá trình tạo horizontal timeline.

Như thường lệ, để lên ý tưởng ban đầu về những gì chúng ta sẽ xây dựng, hãy xem bản demo liên quan trên CodePen (xem phiên bản lớn hơn để có trải nghiệm tốt hơn):

Chúng ta có rất nhiều thứ để tìm hiểu, vì vậy chúng ta hãy bắt đầu nào!

1. Phần HTML

Mã html mà chúng ta định nghĩa giống với mã html mà chúng ta định nghĩa cho vertical timeline, ngoài ba thứ nhỏ này:

  • Chúng ta sử dụng một danh sách có thứ tự thay vì danh sách không có thứ tự vì danh sách này có tính ngữ nghĩa hơn.
  • Có một phần tử danh sách bổ sung (phần tử cuối cùng) là phần tử rỗng. Trong phần sắp tới, chúng ta sẽ thảo luận về lý do.
  • Có một phần tử phụ (đó là .arrows) chịu trách nhiệm điều hướng cho timeline.

Đây là markup cần thiết:

  1. Some content here

Trạng thái ban đầu của timeline trông giống như sau:

2. Thêm các style CSS ban đầu

Sau đó một số phông chữ cơ bản, màu sắc, v.v. Những thứ này tôi đã bỏ qua ở đây vì để cho đơn giản, chúng ta chỉ định một số quy tắc CSS có cấu trúc:

.timeline {
  white-space: nowrap;
  overflow-x: hidden;
}

.timeline ol {
  font-size: 0;
  width: 100vw;
  padding: 250px 0;
  transition: all 1s;
}

.timeline ol li {
  position: relative;
  display: inline-block;
  list-style-type: none;
  width: 160px;
  height: 3px;
  background: #fff;
}

.timeline ol li:last-child {
  width: 280px;
}

.timeline ol li:not(:first-child) {
  margin-left: 14px;
}

.timeline ol li:not(:last-child)::after {
  content: '';
  position: absolute;
  top: 50%;
  left: calc(100% + 1px);
  bottom: 0;
  width: 12px;
  height: 12px;
  transform: translateY(-50%);
  border-radius: 50%;
  background: #F45B69;
}

Quan trọng nhất ở đây, bạn sẽ nhận thấy hai thứ:

  • Chúng ta gán padding top và bottom lớn cho danh sách. Một lần nữa, chúng ta sẽ giải thích lý do cho điều đó trong phần tiếp theo.
  • Như bạn sẽ thấy trong bản demo sau đây, tại thời điểm này, chúng ta không thể nhìn thấy tất cả các phần tử của danh sách bởi vì danh sách có width: 100vw và phần tử cha của nó có overflow-x: hidden. Điều này có tác dụng "che" các phần tử của danh sách. Tuy nhiên, nhờ điều hướng của timeline, chúng ta sẽ có thể điều hướng qua các phần tử sau này.

Sau khi đã có các quy tắc này, thì đây là trạng thái hiện tại của timeline (chưa có bất kỳ nội dung thật sự nào, để giữ cho mọi thứ rõ ràng):

3. Style cho Phần tử Timeline

Tại thời điểm này, chúng ta sẽ thêm style cho các phần tử div (chúng ta sẽ gọi chúng là “các phần tử timeline”) là một phần của các phần tử danh sách cũng như các phần tử giả ::before của chúng.

Ngoài ra, chúng ta sẽ sử dụng các lớp giả CSS :nth-child(odd):nth-child(even) để phân biệt các style cho các div lẻ và chẵn.

Dưới đây là các style chung cho các phần tử timeline:

.timeline ol li div {
  position: absolute;
  left: calc(100% + 7px);
  width: 280px;
  padding: 15px;
  font-size: 1rem;
  white-space: normal;
  color: black;
  background: white;
}

.timeline ol li div::before {
  content: '';
  position: absolute;
  top: 100%;
  left: 0;
  width: 0;
  height: 0;
  border-style: solid;
}

Sau đó, một số style cho những phần tử lẻ:

.timeline ol li:nth-child(odd) div {
  top: -16px;
  transform: translateY(-100%);
}

.timeline ol li:nth-child(odd) div::before {
  top: 100%;
  border-width: 8px 8px 0 0;
  border-color: white transparent transparent transparent;
}

Và cuối cùng là một số style cho những phần tử chẵn:

.timeline ol li:nth-child(even) div {
  top: calc(100% + 16px);
}

.timeline ol li:nth-child(even) div::before {
  top: -8px;
  border-width: 8px 0 0 8px;
  border-color: transparent transparent transparent white;
}

Dưới đây là trạng thái mới của timeline, với nội dung được thêm vào:

Như bạn có thể nhận thấy, các phần tử timeline được thiết lập là absolute. Điều đó có nghĩa là chúng nằm ngoài dòng tài liệu bình thường. Như vậy, để đảm bảo rằng toàn bộ timeline xuất hiện, chúng ta phải thiết lập các giá trị padding top và bottom lớn cho danh sách. Nếu chúng ta không áp dụng bất kỳ padding nào, timeline sẽ bị cắt:

Hướng dẫn timeline horizontal css
Hướng dẫn timeline horizontal css
Hướng dẫn timeline horizontal css

4. Style cho Điều hướng Timeline

Bây giờ là lúc để thêm style cho các nút điều hướng. Hãy nhớ rằng mặc định, chúng ta vô hiệu hóa mũi tên về trước và thêm lớp disabled.

Dưới đây là các stye CSS đi kèm:

.timeline .arrows {
  display: flex;
  justify-content: center;
  margin-bottom: 20px;
}

.timeline .arrows .arrow__prev {
  margin-right: 20px;
}

.timeline .disabled {
  opacity: .5;
}

.timeline .arrows img {
  width: 45px;
  height: 45px;
}

Các quy tắc ở trên cho chúng ta timeline này:

5. Thêm tương tác

Cấu trúc cơ bản của timeline đã sẵn sàng. Hãy thêm một số tương tác cho nó!

Biến

Trước tiên, chúng ta thiết lập một loạt các biến mà chúng ta sẽ sử dụng sau này.

const timeline = document.querySelector(".timeline ol"),
  elH = document.querySelectorAll(".timeline li > div"),
  arrows = document.querySelectorAll(".timeline .arrows .arrow"),
  arrowPrev = document.querySelector(".timeline .arrows .arrow__prev"),
  arrowNext = document.querySelector(".timeline .arrows .arrow__next"),
  firstItem = document.querySelector(".timeline li:first-child"),
  lastItem = document.querySelector(".timeline li:last-child"),
  xScrolling = 280,
  disabledClass = "disabled";

Khởi tạo mọi thứ

Khi tất cả nội dung của trang đã sẵn sàng, hàm init được gọi.

window.addEventListener("load", init);

Hàm này kích hoạt bốn hàm con:

function init() {
  setEqualHeights(elH);
  animateTl(xScrolling, arrows, timeline);
  setSwipeFn(timeline, arrowPrev, arrowNext);
  setKeyboardFn(arrowPrev, arrowNext);
}

Như chúng ta sẽ thấy một lát nữa, mỗi hàm này thực hiện một nhiệm vụ nhất định.

Các phần tử timeline cao ngang nhau

Nếu bạn quay trở lại bản demo gần nhất, bạn sẽ nhận thấy rằng các phần tử timeline không có chiều cao bằng nhau. Điều này không ảnh hưởng đến chức năng chính của timeline của chúng ta, nhưng bạn có thể thích hơn nếu tất cả các phần tử có cùng chiều cao. Để làm được điều này, chúng ta có thể gán cho chúng độ cao cố định thông qua CSS (giải pháp dễ) hoặc chiều cao động tương ứng với chiều cao của phần tử cao nhất thông qua JavaScript.

Cách thứ hai linh hoạt hơn và ổn định hơn, do đó, đây là một hàm cài đặt hành vi này:

function setEqualHeights(el) {
  let counter = 0;
  for (let i = 0; i < el.length; i++) {
    const singleHeight = el[i].offsetHeight;
    
    if (counter < singleHeight) {
      counter = singleHeight;
    }
  }
  
  for (let i = 0; i < el.length; i++) {
    el[i].style.height = `${counter}px`;
  }
}

Hàm này lấy chiều cao của phần timeline cao nhất và thiết lập nó làm chiều cao mặc định cho tất cả các phần tử.

Bản demo sẽ trông giống như sau:

6. Thêm hiệu ứng vào Timeline

Bây giờ hãy tập trung vào hiệu ứng cho timeline. Chúng ta sẽ xây dựng hàm để cài đặt hành vi này theo từng bước.

Trước tiên, chúng ta đăng ký một event listener cho các nút của timeline:

function animateTl(scrolling, el, tl) {
  for (let i = 0; i < el.length; i++) {
    el[i].addEventListener("click", function() {
      // code here
    });
  }
}

Mỗi khi nút được nhấp, chúng ta kiểm tra trạng thái disabled của các nút trên timeline và nếu chúng không disabled, chúng ta sẽ disabled chúng. Điều này đảm bảo rằng cả hai nút sẽ chỉ được nhấp một lần cho đến khi hiệu ứng kết thúc.

Vì vậy, về mặt code, trình xử lý nhấp chuột lúc đầu chứa các dòng này:

if (!arrowPrev.disabled) {
  arrowPrev.disabled = true;
}

if (!arrowNext.disabled) {
  arrowNext.disabled = true;
}

Các bước tiếp theo như sau:

  • Chúng ta kiểm tra xem đây có phải là lần đầu tiên chúng ta nhấp vào một nút hay không. Một lần nữa, hãy nhớ rằng nút previous bị disabled mặc định, do đó, ban đầu nút duy nhất có thể được nhấp là nút next.
  • Nếu thật sự là lần đầu tiên, chúng ta sử dụng thuộc tính transform để di chuyển timeline sang phải 280px. Giá trị của biến xScrolling xác định khoảng di chuyển.
  • Ngược lại, nếu chúng ta đã nhấp vào nút, chúng ta sẽ truy vấn giá trị transform hiện tại của timeline và thêm hoặc xóa giá trị đó, khoảng di chuyển mong muốn (tức là 280px). Vì vậy, miễn là chúng ta nhấp vào nút previous, giá trị của thuộc tính transform giảm và timeline di chuyển từ trái sang phải. Tuy nhiên, khi nút next được nhấp, giá trị của thuộc tính transform tăng lên và timeline di chuyển từ phải sang trái.

Code để cài đặt chức năng này như sau:

let counter = 0; 
for (let i = 0; i < el.length; i++) {
  el[i].addEventListener("click", function() {
    // other code here
  
    const sign = (this.classList.contains("arrow__prev")) ? "" : "-";
    if (counter === 0) {
      tl.style.transform = `translateX(-${scrolling}px)`;
    } else {
      const tlStyle = getComputedStyle(tl);
      // add more browser prefixes if needed here
      const tlTransform = tlStyle.getPropertyValue("-webkit-transform") || tlStyle.getPropertyValue("transform");
      const values = parseInt(tlTransform.split(",")[4]) + parseInt(`${sign}${scrolling}`);
      tl.style.transform = `translateX(${values}px)`;
    }
    counter++;
  });
}

Tốt lắm! Chúng ta vừa xác định được một cách tạo hiệu ứng cho timeline. Thách thức tiếp theo là tìm ra khi nào hiệu ứng này sẽ dừng lại. Đây là cách làm của chúng ta:

  • Khi phần tử đầu tiên của timeline được nhìn thấy hoàn toàn, điều đó có nghĩa là chúng ta đã chạm đến phần đầu của timeline và do đó chúng ta disabled nút previous. Chúng ta cũng đảm bảo rằng nút next được enabled.
  • Khi phần tử cuối cùng hiển thị hoàn toàn, điều đó có nghĩa là chúng ta đã chạm đến phần tử cuối cùng của timeline và do đó chúng ta disabled nút next. Do đó, chúng ta cũng đảm bảo rằng nút previous được enabled.

Hãy nhớ rằng phần tử cuối cùng là phần tử rỗng có chiều rộng bằng với chiều rộng của các phần tử timeline (tức là 280px). Chúng ta gán cho nó giá trị này (hoặc giá trị cao hơn) bởi vì chúng ta muốn đảm bảo rằng phần tử dòng cuối cùng của timeline sẽ hiển thị trước khi vô hiệu hóa nút next.

Để phát hiện xem các phần tử có hiển thị hoàn toàn trong viewport hiện tại hay không, chúng ta sẽ tận dụng code mà chúng ta đã sử dụng cho vertical timeline. Code yêu cầu xuất phát từ chủ đề trên Stack Overflow như sau:

function isElementInViewport(el) {
  const rect = el.getBoundingClientRect();
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
    rect.right <= (window.innerWidth || document.documentElement.clientWidth)
  );
}

Ngoài các hàm ở trên, chúng ta định nghĩa một trình trợ giúp khác:

function setBtnState(el, flag = true) {
  if (flag) {
    el.classList.add(disabledClass);
  } else {
    if (el.classList.contains(disabledClass)) {
      el.classList.remove(disabledClass);
    }
    el.disabled = false;
  }
}

Hàm này thêm hoặc xoá bỏ lớp disabled khỏi một phần tử dựa trên giá trị của tham số flag. Ngoài ra, nó có thể thay đổi trạng thái disabled cho phần tử này.

Với những gì chúng ta đã mô tả ở trên, dưới đây là code mà chúng ta định nghĩa để kiểm tra xem hiệu ứng có nên dừng hay không:

for (let i = 0; i < el.length; i++) {
  el[i].addEventListener("click", function() {
    // other code here
    
    // code for stopping the animation
    setTimeout(() => {
      isElementInViewport(firstItem) ? setBtnState(arrowPrev) : setBtnState(arrowPrev, false);
      isElementInViewport(lastItem) ? setBtnState(arrowNext) : setBtnState(arrowNext, false);
    }, 1100);
  
    // other code here
  });
}

Lưu ý rằng có độ trễ 1.1 giây trước khi thực thi code này. Tại sao điều này xảy ra?

Nếu chúng ta quay lại CSS, chúng ta sẽ thấy quy tắc này:

.timeline ol {
  transition: all 1s;
}

Vì vậy, hiệu ứng của timeline cần 1 giây để hoàn thành. Khi nó hoàn thành, chúng ta chờ đợi 100 mili giây và sau đó, chúng ta thực hiện công việc kiểm tra.

Dưới đây timeline với hiệu ứng động:

7. Thêm hỗ trợ cử chỉ vuốt

Cho đến lúc này, timeline không phản hồi các sự kiện chạm. Sẽ rất tuyệt nếu chúng ta có thể thêm chức năng này. Để thực hiện việc đó, chúng ta có thể cài đặt bằng JavaScript của riêng mình hoặc sử dụng một trong các thư viện hỗ trợ (ví dụ, Hammer.js, TouchSwipe.js).

Đối với bản demo của chúng ta, chúng ta sẽ làm đơn giản và sử dụng Hammer.js, vì vậy trước tiên, chúng ta bao gồm thư viện này vào pen của chúng ta:

Hướng dẫn timeline horizontal css
Hướng dẫn timeline horizontal css
Hướng dẫn timeline horizontal css

Sau đó, chúng ta khai báo hàm liên quan:

function setSwipeFn(tl, prev, next) {
  const hammer = new Hammer(tl);
  hammer.on("swipeleft", () => next.click());
  hammer.on("swiperight", () => prev.click());
}

Bên trong hàm ở trên, chúng ta làm những việc sau đây:

  • Tạo một đối tượng Hammer.
  • Đăng ký trình xử lý cho các sự kiện swipeleftswiperight.
  • Khi chúng ta vuốt timeline theo hướng bên trái, chúng ta sẽ kích hoạt một cú nhấp vào nút next và do đó timeline sẽ chạy từ phải sang trái.
  • Khi chúng ta vuốt timeline theo hướng phải, chúng ta sẽ kích hoạt cú nhấp vào nút previous và do đó timeline sẽ di chuyển từ trái sang phải.

Timeline với hỗ trợ vuốt:

Thêm điều hướng bằng bàn phím

Hãy nâng cao hơn nữa trải nghiệm người dùng bằng cách cung cấp hỗ trợ cho điều hướng bằng bàn phím. Mục tiêu của chúng ta là:

  • Khi nhấn phím mũi tên trái hoặc phải, trang sẽ được cuộn đến vị trí trên cùng của timeline (nếu một phần trang khác đang hiển thị). Điều này đảm bảo rằng toàn bộ timeline sẽ hiển thị.
  • Cụ thể, khi nhấn phím mũi tên trái, timeline sẽ di chuyển từ trái sang phải.
  • Tương tự, khi nhấn phím mũi tên phải, timeline sẽ di chuyển từ phải sang trái.

Hàm cài đặt hành vi này sẽ như sau:

function setKeyboardFn(prev, next) {
  document.addEventListener("keydown", (e) => {
    if ((e.which === 37) || (e.which === 39)) {
      const timelineOfTop = timeline.offsetTop;
      const y = window.pageYOffset;
      if (timelineOfTop !== y) {
        window.scrollTo(0, timelineOfTop);
      } 
      if (e.which === 37) {
        prev.click();
      } else if (e.which === 39) {
        next.click();
      }
    }
  });
}

Timeline với hỗ trợ bàn phím:

8. Làm cho nó Responsive

Chúng ta gần như xong! Cuối cùng nhưng không kém phần quan trọng, hãy làm cho timeline trở nên responsive. Khi viewport nhỏ hơn 600px, nó nên có bố cục xếp chồng như sau:

Hướng dẫn timeline horizontal css
Hướng dẫn timeline horizontal css
Hướng dẫn timeline horizontal css

Vì chúng ta đang sử dụng cách tiếp cận desktop-first, nên đây là các quy tắc CSS mà chúng ta phải thay thế:

@media screen and (max-width: 599px) {
  .timeline ol,
  .timeline ol li {
    width: auto; 
  }
  
  .timeline ol {
    padding: 0;
    transform: none !important;
  }
  
  .timeline ol li {
    display: block;
    height: auto;
    background: transparent;
  }
  
  .timeline ol li:first-child {
    margin-top: 25px;
  }
  
  .timeline ol li:not(:first-child) {
    margin-left: auto;
  }
  
  .timeline ol li div {
    width: 94%;
    height: auto !important;
    margin: 0 auto 25px;
  }
  
  .timeline ol li:nth-child div {
    position: static;
  }
  
  .timeline ol li:nth-child(odd) div {
    transform: none;
  }
  
  .timeline ol li:nth-child(odd) div::before,
  .timeline ol li:nth-child(even) div::before {
    left: 50%;
    top: 100%;
    transform: translateX(-50%);
    border: none;
    border-left: 1px solid white;
    height: 25px;
  }
  
  .timeline ol li:last-child,
  .timeline ol li:nth-last-child(2) div::before,
  .timeline ol li:not(:last-child)::after,
  .timeline .arrows {
    display: none;
  }
}

Lưu ý: Đối với hai trong số các quy tắc ở trên, chúng ta phải sử dụng quy tắc !important để ghi đè các style nội tuyến có liên quan được áp dụng thông qua JavaScript.

Trạng thái cuối cùng của timeline của chúng ta:

Trình duyệt Hỗ trợ

Bản demo hoạt động tốt trên tất cả các trình duyệt và thiết bị mới. Ngoài ra, như bạn có thể nhận ra, chúng ta sử dụng Babel để biên dịch mã ES6 xuống ES5.

Một vấn đề nhỏ mà tôi gặp phải trong khi thử nghiệm đó là thay đổi kết xuất văn bản xảy ra khi timeline đang chạy hiệu ứng. Mặc dù tôi đã thử các cách tiếp cận khác nhau được đề xuất trong các chủ đề trên Stack Overflow, tôi vẫn chưa tìm thấy giải pháp đơn giản cho tất cả các hệ điều hành và trình duyệt. Vì vậy, hãy nhớ rằng bạn có thể thấy các vấn đề về hiển thị phông chữ nhỏ khi timeline đang chạy hiệu ứng.

Phần kết luận

Trong bài hướng dẫn khá thực tế này, chúng ta bắt đầu với một danh sách có thứ tự đơn giản và tạo ra một responsive horzontal timeline. Không nghi ngờ gì nữa, chúng ta đã bao quát rất nhiều điều thú vị, nhưng tôi hy vọng bạn thích làm việc hướng tới kết quả cuối cùng và điều đó giúp bạn học được một số kiến ​​thức mới.

Nếu bạn có bất kỳ câu hỏi nào hoặc nếu có bất kỳ điều gì bạn không hiểu, hãy cho tôi biết trong phần bình luận bên dưới nhé!

Làm gì Tiếp theo?

Nếu bạn muốn cải tiến thêm hoặc mở rộng timeline này, thì đây là một vài điều bạn có thể làm:

  • Thêm hỗ trợ cho việc kéo. Thay vì nhấp vào các nút trên timeline để điều hướng, chúng ta chỉ cần kéo timeline. Đối với hành vi này, bạn có thể sử dụng tính năng Drag and Drop Api gốc (rất tiếc là không hỗ trợ thiết bị di động tại thời điểm bài viết) hoặc thư viện bên ngoài như Draggable.js.
  • Cải tiến hành vi của timeline khi chúng ta thay đổi kích thước cửa sổ trình duyệt. Ví dụ, khi chúng ta thay đổi kích thước cửa sổ, các nút sẽ được disabled và enabled cho phù hợp.
  • Sắp xếp code theo cách dễ quản lý hơn. Có thể sử dụng một JavaScript Design Pattern phổ biến.