9 CSS Border Animations — dán 1 dòng là chạy
CSS border animation là hiệu ứng viền của card hay ô nhập di chuyển, phát sáng hoặc đổi màu bằng thuần CSS để báo một trạng thái đang diễn ra.
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Viền quét sáng chéo
- 02 Vẽ viền bằng SVG
- 03 Nét đứt chạy quanh viền
- 04 Viền phát sáng nhấp nháy
- 05 Ngoặc góc
- 06 Gạch chân mở rộng
- 07 Viền vòng đôi
- 08 Viền cầu vồng
- 09 Vòng focus có animation
Thứ tự chín viền dưới đây đi theo loại tín hiệu mà chúng đang báo: 01 đến 04 báo có gì đó đang xử lý hoặc đang tải, 05 và 06 đánh dấu chỗ bạn vừa chọn hoặc vừa đưa focus tới, 07 chỉ phản hồi khi bạn hover vào, 08 dành cho một khoảnh khắc đặc biệt, còn 09 là vòng focus dành riêng cho bàn phím. Mỗi phần dưới đây chỉ trích đoạn SCSS quyết định chuyển động, còn phần thiết lập HTML đầy đủ của cả chín file nằm gọn trong bản nén ở cuối bài.
01Viền quét sáng chéo
Một vòng mỏng cắt đúng hình viền mang gradient sọc chéo, rồi trượt background-position không ngừng khiến cả khung trông như đang chảy sáng cùng lúc. Bạn đặt kiểu này ở viền ô input trong lúc AI đang trả lời, hoặc quanh card gói cước cao cấp.
.frame::before {
content: "";
position: absolute; inset: 0; padding: 3px;
background: repeating-linear-gradient(115deg, $color 0 10px, $subject-cream 10px 14px, transparent 14px 34px);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: sweep $duration linear infinite;
}
@keyframes sweep { to { background-position: 220px 0; } }
02Vẽ viền bằng SVG
Viền card được vẽ bằng thẻ rect trong SVG với pathLength="100", nên bạn luôn làm việc với thang 0–100 bất kể chu vi thật của card là bao nhiêu pixel. Giảm stroke-dashoffset từ 100 về 0 khiến đường viền nối lại như một cây bút đang vẽ, hợp với khoảnh khắc thẻ vừa được phát hành.
// pathLength="100" trên rect — làm việc theo thang 0–100, không quan tâm chu vi thật là bao nhiêu px
.card__rect {
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation: draw-loop $dur-loop $easing infinite;
}
@keyframes draw-loop {
0%, 5% { stroke-dashoffset: 100; }
35%, 60% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
03Nét đứt chạy quanh viền
Mỗi cạnh trong số bốn cạnh mang một nền gradient nét đứt của riêng nó, và trượt background-position theo đúng hướng cạnh đó khiến nét đứt trông như đang chạy vòng theo chiều kim đồng hồ. Đặt nó ở viền của card đang tải lên, hoặc ở bất kỳ đâu cần báo trạng thái chờ.
.frame__edge--top, .frame__edge--bottom {
background-image: repeating-linear-gradient(90deg, $color 0 8px, transparent 8px 16px);
}
.frame__edge--left, .frame__edge--right {
background-image: repeating-linear-gradient(180deg, $color 0 8px, transparent 8px 16px);
}
.frame.is-demo .frame__edge--top { animation: run-x $duration linear infinite; }
.frame.is-demo .frame__edge--bottom { animation: run-x-rev $duration linear infinite; }
@keyframes run-x { to { background-position: 32px 0; } }
@keyframes run-x-rev { to { background-position: -32px 0; } }
04Viền phát sáng nhấp nháy
Một giá trị kiểu <length> mang tên --glow được đăng ký qua @property rồi animate từ 0 lên 16px, nên bán kính mờ của box-shadow lớn dần và nhỏ lại mượt mà thay vì nhảy khựng. Đây là kiểu bạn gắn cho badge thông báo hay card đang cập nhật theo thời gian thực.
@property --glow {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
.badge {
box-shadow: 0 0 var(--glow) 0 rgba($color, .55);
animation: glow $duration $easing infinite;
}
@keyframes glow { 0%, 100% { --glow: 0px; } 50% { --glow: 16px; } }
05Ngoặc góc
Bốn ngoặc chữ L đặt riêng ở bốn góc, mỗi cái mở ra theo đúng hướng chéo của góc đó khi hover hoặc focus, tạo cảm giác giống khung lấy nét của máy ảnh đang mở ra. Card trong thư viện ảnh dùng kiểu này để đánh dấu ảnh bạn vừa chọn.
.frame__bracket--tl { top: -3px; left: -3px; border-right: 0; border-bottom: 0; }
.frame__bracket--tr { top: -3px; right: -3px; border-left: 0; border-bottom: 0; }
.frame.is-demo .frame__bracket--tl { animation: open-tl $dur-loop $easing infinite; }
@keyframes open-tl {
0%, 15% { transform: translate(0, 0); }
50%, 70% { transform: translate(-6px, -6px); }
100% { transform: translate(0, 0); }
}
06Gạch chân mở rộng
Thanh chỉ báo dưới ô nhập giữ left: 50%; right: 50% để gập lại ở giữa, và khi ô nhập nhận focus cả hai giá trị chuyển về 0 cùng lúc nên thanh nở ra đều về hai bên. Dùng left/right thay vì scaleX vì cách này cho phép mở rộng đúng tâm mà không cần khai riêng transform-origin.
.field__bar {
position: absolute;
left: 50%; right: 50%; bottom: -2px;
background: $color;
transition: left $duration $easing, right $duration $easing;
}
.field__input:focus + .field__bar { left: 0; right: 0; }
07Viền vòng đôi
box-shadow giữ một vòng trong cố định, còn ::after dựng thêm vòng thứ hai vốn ẩn đi lúc bình thường, khi hover hay focus nó scale lớn dần trong lúc opacity tăng dần nên trông như gợn sóng lan ra. Đây là cách nhấn hover cho nút bấm hay icon thông báo.
.btn::after {
content: "";
position: absolute; inset: -3px;
border: 2px solid $color;
opacity: 0;
transform: scale(1);
transition: transform $duration $easing, opacity $duration $easing;
}
.btn:hover::after, .btn:focus-visible::after { opacity: 1; transform: scale(1.3); }
08Viền cầu vồng
border-image-source giữ nguyên một gradient cầu vồng đứng yên, chỉ mỗi giá trị filter: hue-rotate quay từ 0deg đến 360deg khiến màu viền tuần hoàn tại chỗ mà gradient không hề dịch chuyển — khác với cú trượt nền ở mục 01. Banner sự kiện hay thiệp chúc mừng hợp với sắc màu xoay vòng này.
.badge {
border: 4px solid;
border-image-slice: 1;
border-image-source: linear-gradient(90deg, $error, $stage-orange, $stage-yellow, $subject-blue, $error);
animation: hue $duration linear infinite;
}
@keyframes hue { to { filter: hue-rotate(360deg); } }
09Vòng focus có animation
outline giữ trong suốt lúc bình thường, chỉ khi :focus-visible mới đổi màu và kéo outline-offset từ 0 lên 4px cùng lúc nên vòng trông như bật ra rồi dừng lại. Nó không phản ứng với cú click chuột, chỉ phản ứng khi focus đến từ bàn phím.
.field__input {
outline: 2px solid transparent;
outline-offset: 0;
transition: outline-color $duration $easing, outline-offset $duration $easing;
}
.field__input:focus-visible { outline-color: $color; outline-offset: 4px; }
Bẫy nằm ở đâu?
01 dựa vào mask-composite để chỉ giữ lại phần viền và khoét bỏ phần giữa. Không kèm tiền tố, thuộc tính này chạy được từ Chrome/Edge 120, Safari 15.4 và Firefox 53 (trang mask-composite của MDN); Chrome cũ hơn mốc đó chỉ đọc được bản có tiền tố -webkit-mask-composite: xor. Bỏ sót một trong hai dòng thì bản demo bạn tự dựng và test trên máy chạy đẹp, nhưng trên trình duyệt cũ hơn cả khối viền lại hiện ra như một ô vuông đặc, không khoét được phần giữa như mong đợi.
Còn một điểm khác đáng nhớ ở 04, thuộc tính @property. Nếu chỉ animate một custom property mà không đăng ký kiểu dữ liệu, trình duyệt không nội suy được giá trị giữa hai mốc nên bước nhảy diễn ra tức thì thay vì mượt. Khai syntax: "<length>" qua @property mới khiến bán kính mờ của 04 lớn nhỏ trơn tru, và cú đăng ký đó chạy từ Chrome/Edge 85, Safari 16.4, Firefox 128 (trang @property của MDN). Bộ file đã gộp sẵn cả hai điểm vừa nói nằm trong bản nén, và mật khẩu mở nó là 4vruzyhz, gõ đúng như bạn thấy, không thêm khoảng trắng.
Điểm thứ ba nằm ở 08, thuộc tính filter. filter vẽ lại toàn bộ nội dung con bên trong phần tử, nên nếu badge chứa nhiều chữ hay icon thì mỗi vòng quay hue-rotate sẽ buộc trình duyệt hợp thành lại cả vùng đó. Viền cầu vồng nên dừng ở kích thước badge nhỏ, đừng gắn lên cả một card lớn hay khung chứa rộng.
Khả năng tiếp cận
Chín viền đều lắng nghe prefers-reduced-motion: reduce, nhưng không phải viền nào cũng chỉ đơn giản tắt animation. 01, 03 và 08 đứng lại ở một khung hình tĩnh thay vì tiếp tục trượt hay xoay, còn 04 giữ nguyên bán kính mờ ở giá trị giữa. 09 là ngoại lệ có chủ đích: vòng focus bàn phím không phải trang trí mà là tín hiệu "bạn đang ở đây", nên chỉ transition bị tắt còn màu vẫn giữ nguyên.
@media (prefers-reduced-motion: reduce) {
.frame.is-demo::before, .badge.is-demo, .field__bar { animation: none; transition: none; }
}
Bạn xem thêm các hiệu ứng CSS khác trong chuyên mục CSS, và trang giới thiệu nói rõ site này bán gì.
FAQ
Có thể lấy đúng một hiệu ứng trong chín cái, bỏ phần còn lại không?
Được. Mỗi hiệu ứng đóng gói trong một khối class riêng như .frame, .badge, .btn hay .field, không chia sẻ trạng thái với mục khác, nên copy phần SCSS của một mục cùng khung HTML đi kèm là chạy độc lập ngay. Mục 06 và 09 là hai bản gọn nhất nếu bạn chỉ cần một chỉ báo focus đơn giản.
mask-composite thay bằng border-image được không?
Được một phần. 08 chỉ cần màu đứng yên nên border-image là đủ gọn, nhưng 01 cần trượt nền theo thời gian thực, mà border-image không có thuộc tính tương đương background-position để làm việc đó, nên buộc bạn quay lại mask-composite hoặc SVG như 02.
Bản React trong zip nhận biến CSS qua đâu?
Thư mục react/ trong file nén nhận duration, easing, color làm props rồi ghi vào style inline dưới dạng custom property. Các module SCSS chỉ đọc var(--duration) như vậy, nên đổi tốc độ hay màu chỉ cần sửa props ở nơi gọi component.