GODRICH

9 CSS Marquee Infinite Loop — chỉ CSS, không JS

CSS marquee infinite loop là kỹ thuật giúp một dải nội dung trôi liên tục, không lộ đường nối, dựng hoàn toàn bằng CSS và không cần một dòng JS nào.

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

Chín demo dưới đây không xếp theo độ nổi bật mà theo lớp kỹ thuật chồng dần lên nhau: công thức không lộ đường nối (01) được đưa thẳng vào một cách bố trí thực tế, hay được dùng (02), rồi cộng thêm tương tác (03) và làm mịn thị giác (04), sau đó đổi trục và nhân đôi hướng chạy (05–06), khép lại bằng nhịp độ và chiều sâu (07–08) trước khi dừng ở khả năng tiếp cận (09). Khung iframe cố định phía trên không nhận sự kiện cuộn hay hover thật nên mỗi ô tự lặp lại một chu kỳ hai giây bằng keyframes riêng của nó — trên một trang thật, một vòng thường mất từ mười đến hai mươi giây, và tên logo hay dòng chữ trong demo chỉ là chỗ để bạn thay bằng nội dung thật.

01Công thức loop liền mạch

Sáu chip được nhân đôi thành mười hai và xếp chung một track flex, mỗi chip mang margin-right cố định thay vì gap của track, rồi transform chỉ dịch từ 0 đến đúng -50% — nhờ vậy bề rộng của một bộ sáu chip đúng bằng nửa track, bản sao rơi đúng vào chỗ bản gốc từng đứng mà không lệch một pixel nào.

translateX(-50%)콘텐츠 2벌@keyframes
.marquee__track {
  display: flex;
  width: max-content;
  animation: marquee-scroll 12s linear infinite;
}
.marquee__chip {
  margin-right: 14px;
}
@keyframes marquee-scroll {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

02Marquee logo vô hạn

Năm wordmark đối tác được xếp thành thẻ viên thuốc rồi chảy qua đúng track liền mạch của mục 01, còn năm thẻ nhân bản phía sau mang aria-hidden="true" để trình đọc màn hình chỉ đọc từng tên đối tác một lần.

로고 카드gap 고정폭linear infinite
.marquee__track {
  display: flex;
  animation: logo-scroll 12s linear infinite;
}
.marquee__logo {
  margin-right: 16px;
  height: 34px;
  padding: 0 16px;
  border-radius: 999px;
  background: #fff;
}

03Dừng khi hover

Ngay khi con trỏ chạm vào banner, animation-play-state chuyển sang paused và giữ nguyên đúng khung hình đang chạy, không giật ngược về đầu vòng lặp như nhiều bản tự viết vẫn mắc phải.

animation-play-statepausedhover
.marquee:hover .marquee__track,
.marquee:focus-within .marquee__track {
  animation-play-state: paused;
}
@keyframes marquee-hover-cycle {
  0%   { transform: translateX(0); }
  38%  { transform: translateX(-32%); }
  62%  { transform: translateX(-32%); }
  100% { transform: translateX(-50%); }
}

04Làm mờ hai đầu bằng mask

Một linear-gradient được gán vào mask-image, kèm bản có tiền tố -webkit-mask-image cho Safari, phủ lên hai đầu track khiến từng thẻ mờ dần vào nền thay vì bị cắt cụt như khi chỉ dùng overflow: hidden.

mask-imagelinear-gradient-webkit-mask
.marquee {
  overflow: hidden;
  mask-image: linear-gradient(to right,
    transparent, #000 14%, #000 86%, transparent);
  -webkit-mask-image: linear-gradient(to right,
    transparent, #000 14%, #000 86%, transparent);
}

05Ticker chạy dọc

Đúng công thức nhân đôi của mục 01 được chuyển sang trục dọc — translateY thay cho translateX — để một dòng gồm tên và giá cuộn lên bên trong một khung có chiều cao cố định.

translateY(-50%)column 트랙고정 높이
.ticker__track {
  display: flex;
  flex-direction: column;
  animation: ticker-scroll 12s linear infinite;
}
@keyframes ticker-scroll {
  from { transform: translateY(0); }
  to   { transform: translateY(-50%); }
}

06Hai dòng chạy ngược chiều

Hai track dùng chung một animation-name và cùng thời lượng, chỉ hàng phía dưới nhận thêm animation-direction: reverse nên hai dòng chip trôi ngược chiều nhau mà không cần viết thêm một bộ keyframes nào khác.

animation-direction: reverse두 트랙교차 흐름
.marquee__track {
  animation-name: marquee-scroll;
  animation-duration: 12s;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
}
.marquee__row--rev .marquee__track {
  animation-direction: reverse;
}

07Nhịp độ nhanh chậm

Thay timing-function linear bằng cubic-bezier(.65,0,.35,1) khiến vòng lặp chậm lại ngay tại đường nối rồi nhanh hơn ở giữa quãng đường, trong khi quãng dịch chuyển vẫn giữ nguyên đúng -50% như công thức gốc.

cubic-bezier비선형 easing이음매 유지
.marquee__track {
  animation: marquee-scroll 9s
    cubic-bezier(.65, 0, .35, 1) infinite;
}
@keyframes marquee-scroll {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

08Marquee cong 3D

Stage nhận perspective còn track nhận preserve-3d, rồi mỗi thẻ trong năm thẻ được gán một bước rotateY riêng qua nth-child để cả dải uốn cong thành mặt trong của một hình trụ.

perspectivepreserve-3d단계별 rotateY
.stage { perspective: 420px; }
.marquee__track { transform-style: preserve-3d; }
.marquee__card:nth-child(1),
.marquee__card:nth-child(6) {
  transform: rotateY(-44deg) translateZ(-40px);
}
.marquee__card:nth-child(3),
.marquee__card:nth-child(8) {
  transform: rotateY(0deg) translateZ(0px);
}

09Bản tĩnh khi giảm chuyển động

Công tắc trên demo cho xem song song track đang chạy và trạng thái tĩnh mà prefers-reduced-motion: reduce thực sự hiển thị cho người dùng thật, dừng đúng khung hình đang ở giữa vòng lặp chứ không phải khung đầu tiên.

prefers-reduced-motion정적 목록토글 비교
@media (prefers-reduced-motion: reduce) {
  .marquee__track {
    animation: none !important;
    transform: translateX(-30%);
  }
  .panel__toggle-knob {
    transform: translateX(16px);
  }
}

Bẫy nằm ở đâu? — hai chỗ dễ hỏng

Lần đầu dựng mục 02, năm thẻ logo nhân bản không được gắn thuộc tính ẩn khỏi trình đọc màn hình. Bật thử một trình đọc màn hình thì mười cái tên được đọc hết, tưởng như có mười đối tác trong khi thực chỉ có năm. Trang tham chiếu aria-hidden của MDN nói rõ thuộc tính này chỉ giấu phần tử khỏi công nghệ hỗ trợ chứ không giấu khỏi màn hình, nên chỉ cần gắn aria-hidden="true" cho từng span nhân bản là giọng đọc dừng lại đúng ở năm cái tên gốc.

Bản dựng đầu của mục 06 lại vấp bẫy khác, và bẫy này lặp lại ở tám trong chín mục: mỗi mục nhân đôi bộ chip gốc rồi xếp chung một track có gap, nhưng vì gap chỉ nằm giữa hai chip liền kề nên tổng số khoảng gap luôn ít hơn tổng số chip đúng một, khiến translateX(-50%) hụt mất nửa gap và mỗi vòng lặp giật một cái rất ngắn ngay tại đường nối — đo trên Chrome, mục 06 giật 5,8px, còn cả tám mục dao động 4–20px tuỳ độ rộng gap của từng mục. Đổi khoảng cách đó từ gap của track sang margin-right trên chính từng chip — bề rộng một bộ chip khi đó đúng bằng nửa track theo pixel — đưa đường nối của cả tám mục xuống còn 0–0,2px, coi như không còn giật.

Chín file đã được canh lại đường nối và sửa cả hai lỗi trên, nằm gọn trong một file nén, mở bằng mật khẩu gcjydr57, gõ đúng như nó hiện ở đây thì file sẽ mở.

Khả năng tiếp cận

Cả chín demo đều tắt animation dưới prefers-reduced-motion: reduce và giữ track đứng yên ở vị trí cuối thay vì biến mất khỏi màn hình. Mục 03 còn giữ nguyên trạng thái dừng đó khi bàn phím điều hướng tới banner qua focus-within, nên người dùng Tab qua không bao giờ bỏ lỡ dòng chữ đang đọc dở. Mục 09 đi xa hơn một bước — nó dựng sẵn cả hai trạng thái cạnh nhau trên cùng một công tắc để bạn nhìn trực tiếp sự khác biệt thay vì phải đổi cài đặt hệ điều hành rồi tải lại trang mới thấy. Ticker dọc ở mục 05 tuân theo đúng quy tắc đó, không có ngoại lệ nào cho trục dọc. Trang prefers-reduced-motion của MDN liệt kê hai giá trị của media feature này cùng mức hỗ trợ theo từng trình duyệt.

Nếu bạn muốn xem thêm demo chạy theo cuộn trang, mục lục nằm trong danh mục CSS, còn lý do trang này chọn đúng chín kiểu marquee để viết nằm ở trang giới thiệu.

FAQ

Có bắt buộc phải nhân đôi nội dung thành hai bản y hệt không?

Có, với đúng công thức -50% dùng ở tám trong chín mục. Vòng lặp chỉ liền mạch khi bản sao là bản sao chính xác của bản gốc, kể cả khoảng cách margin-right giữa các phần tử; thiếu một item hay lệch một khoảng cách thì đường nối lộ ra ngay ở khung hình cuối mỗi vòng.

Chín marquee này có xung đột với một trang dùng Tailwind không?

Không. Chín file biên dịch từ SCSS ra CSS thuần, không đụng vào cấu hình build nào cả, nên display: flex, gap@keyframes map thẳng sang các utility tương ứng của Tailwind. Chỉ riêng mục 08 cần khai báo perspectivetransform-style: preserve-3d ngoài bộ utility mặc định vì đó là thuộc tính 3D mà Tailwind ít khi có sẵn utility class riêng.

Marquee chạy bằng CSS này có nặng hơn một thư viện marquee viết bằng JS không?

Nhẹ hơn. Cả chín mục chỉ animate transform, thuộc tính mà trình duyệt xử lý ở bước hợp thành mà không phải tính lại layout mỗi khung hình, còn phần lớn thư viện JS phải đo lại chiều rộng nội dung bằng tay ở mỗi lần render. Cái giá phải trả là bạn tự chịu trách nhiệm giữ hai bản nội dung khớp nhau tuyệt đối, việc mà một thư viện JS thường tự động hoá.

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.