CSS Hiện Đại năm 2026: Các Tính Năng Thay Đổi Cách Tôi Viết Styles
Container queries, CSS layers, :has(), color-mix(), nesting, scroll-driven animations và anchor positioning. Các tính năng CSS khiến tôi ngừng dùng JavaScript.
Tôi đã xóa file Sass cuối cùng của mình sáu tháng trước. Không phải vì tôi muốn tuyên bố điều gì đó. Mà vì tôi thực sự không cần nó nữa.
Trong hơn một thập kỷ, CSS là ngôn ngữ mà chúng ta phải xin lỗi khi dùng. Chúng ta cần preprocessor cho nesting và biến. Chúng ta cần JavaScript cho kích thước dựa trên container, animation liên kết cuộn, chọn phần tử cha, và một nửa các pattern bố cục mà designer đưa cho chúng ta. Chúng ta đã xây dựng cả hệ thống runtime — thư viện CSS-in-JS, các framework tiện ích, chuỗi plugin PostCSS — để bù đắp cho những gì ngôn ngữ không thể làm nguyên bản.
Thời đại đó đã kết thúc. Không phải "gần kết thúc." Không phải "đang tiến tới." Kết thúc rồi.
Các tính năng đã xuất hiện trong trình duyệt từ 2024 đến 2026 không chỉ thêm sự tiện lợi. Chúng đã thay đổi mô hình tư duy. CSS không còn là ngôn ngữ styling mà bạn phải chiến đấu chống lại. Nó là ngôn ngữ styling thực sự biết nghĩ về component, quản lý specificity, thiết kế responsive ở cấp phần tử, và animation không cần runtime.
Đây là những gì đã thay đổi, tại sao nó quan trọng, và những gì bạn có thể ngừng làm nhờ nó.
Container Queries: Kết Thúc Câu Hỏi "Viewport Rộng Bao Nhiêu?"#
Đây là tính năng đã thay đổi căn bản cách tôi nghĩ về thiết kế responsive. Trong hai mươi năm, chúng ta có media queries. Media queries hỏi: "viewport rộng bao nhiêu?" Container queries hỏi: "container chứa component này rộng bao nhiêu?"
Sự khác biệt đó nghe có vẻ nhỏ. Nhưng không phải. Đó là sự khác biệt giữa các component chỉ hoạt động trong một ngữ cảnh bố cục và các component hoạt động ở mọi nơi.
Vấn Đề Tồn Tại Suốt Hai Thập Kỷ#
Hãy xem xét một component card. Trong sidebar, nó nên xếp dọc với hình ảnh nhỏ. Trong vùng nội dung chính, nó nên nằm ngang với hình ảnh lớn hơn. Trong phần hero toàn chiều rộng, nó nên là thứ hoàn toàn khác.
Với media queries, bạn sẽ viết như thế này:
/* The old way: coupling component styles to page layout */
.card {
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.sidebar .card {
/* Still vertical in sidebar */
}
.main-content .card {
flex-direction: row;
/* Horizontal in main area */
}
}
@media (min-width: 1200px) {
.hero .card {
/* Yet another layout */
}
}Card biết về .sidebar, .main-content, và .hero. Nó biết về trang. Nó không còn là component nữa — nó là một phần biết về trang. Di chuyển nó sang trang khác và mọi thứ sẽ hỏng.
Container Queries Sửa Điều Này Hoàn Toàn#
/* The container query way: component knows only about itself */
.card-wrapper {
container-type: inline-size;
container-name: card;
}
.card {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@container card (min-width: 400px) {
.card {
grid-template-columns: 200px 1fr;
}
}
@container card (min-width: 700px) {
.card {
grid-template-columns: 300px 1fr;
gap: 2rem;
font-size: 1.125rem;
}
}Card không biết nó sống ở đâu. Nó không quan tâm. Đặt nó trong sidebar 300px và nó sẽ xếp dọc. Đặt nó trong vùng chính 700px và nó sẽ nằm ngang. Thả nó vào phần toàn chiều rộng và nó sẽ tự thích ứng. Không cần biết gì về bố cục trang.
inline-size vs size#
Bạn gần như luôn muốn dùng container-type: inline-size. Điều này cho phép truy vấn trên trục inline (chiều rộng trong chế độ viết ngang). Sử dụng container-type: size cho phép truy vấn cả trục inline và block, nhưng nó yêu cầu container phải có kích thước rõ ràng ở cả hai chiều, điều này phá vỡ luồng tài liệu bình thường trong hầu hết các trường hợp.
/* This is what you want 99% of the time */
.wrapper {
container-type: inline-size;
}
/* This requires explicit height — rarely what you want */
.wrapper-both {
container-type: size;
height: 500px; /* required, or it collapses */
}Container Có Tên Cho Ngữ Cảnh Lồng Nhau#
Khi bạn lồng các container, việc đặt tên trở nên thiết yếu:
.page-layout {
container-type: inline-size;
container-name: layout;
}
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
/* Target the sidebar specifically, not the nearest ancestor */
@container sidebar (max-width: 250px) {
.nav-item {
font-size: 0.875rem;
padding: 0.25rem;
}
}
/* Target the page layout */
@container layout (min-width: 1200px) {
.page-header {
font-size: 2.5rem;
}
}Không có tên, @container sẽ truy vấn tổ tiên gần nhất có containment. Với lồng nhau, đó thường không phải container bạn muốn. Hãy đặt tên cho chúng. Luôn luôn.
Đơn Vị Container Query#
Tính năng này bị đánh giá thấp. Các đơn vị container query (cqi, cqb, cqw, cqh) cho phép bạn định kích thước tương đối với container, không phải viewport:
@container (min-width: 400px) {
.card-title {
font-size: clamp(1rem, 4cqi, 2rem);
}
}4cqi là 4% kích thước inline của container. Tiêu đề scale theo container, không phải theo cửa sổ. Đây mới chính là fluid typography đáng lẽ phải như vậy từ đầu.
CSS Layers: Cuộc Chiến Specificity Đã Kết Thúc#
Nếu container queries thay đổi cách tôi nghĩ về thiết kế responsive, thì @layer thay đổi cách tôi nghĩ về kiến trúc CSS. Lần đầu tiên, chúng ta có cách quản lý specificity hợp lý, khai báo rõ ràng trên toàn bộ dự án.
Vấn Đề#
Specificity trong CSS là hệ thống điểm không quan tâm đến ý định của bạn. Một lớp tiện ích .text-red thua .card .title vì cái sau có specificity cao hơn. Cách sửa luôn là: làm selector cụ thể hơn, thêm !important, hoặc tái cấu trúc mọi thứ.
Chúng ta đã xây dựng cả phương pháp luận (BEM, SMACSS, ITCSS) và chuỗi công cụ chỉ để tránh xung đột specificity. Tất cả đều là giải pháp tạm cho một tính năng ngôn ngữ còn thiếu.
Thứ Tự Layer#
@layer cho phép bạn khai báo thứ tự mà các nhóm style được xem xét, bất kể specificity bên trong các nhóm đó:
/* Declare layer order — this single line controls everything */
@layer reset, base, components, utilities, overrides;
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
@layer base {
body {
font-family: system-ui, sans-serif;
line-height: 1.6;
color: oklch(20% 0 0);
}
h1, h2, h3 {
line-height: 1.2;
text-wrap: balance;
}
}
@layer components {
.card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px oklch(0% 0 0 / 0.12);
}
.card .title {
font-size: 1.25rem;
font-weight: 600;
color: oklch(25% 0.05 260);
}
}
@layer utilities {
.text-red {
color: oklch(55% 0.25 25);
}
.hidden {
display: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
}Mặc dù .card .title có specificity cao hơn .text-red, tiện ích vẫn thắng vì layer utilities được khai báo sau components. Không cần !important. Không cần hack specificity. Thứ tự layer là quyết định cuối cùng.
Cách Tailwind CSS v4 Sử Dụng Layers#
Tailwind v4 dựa nhiều vào @layer. Khi bạn viết @import "tailwindcss", bạn nhận được:
@layer theme, base, components, utilities;Mọi tiện ích Tailwind nằm trong layer utilities. Style component tùy chỉnh của bạn nằm trong components. Đây là lý do tại sao class text-red-500 có thể ghi đè màu của component mà không cần !important — nó nằm trong layer sau.
Nếu bạn đang xây dựng design system riêng không dùng Tailwind, hãy tham khảo kiến trúc này. Đó là kiến trúc đúng:
@layer reset, tokens, base, layouts, components, utilities, overrides;Bảy layer là đủ. Tôi chưa bao giờ cần nhiều hơn.
Style Không Thuộc Layer Thắng Tất Cả#
Một lưu ý: style không thuộc bất kỳ layer nào có ưu tiên cao nhất. Điều này thực sự hữu ích — nghĩa là các override đặc thù cho trang sẽ tự động thắng:
@layer components {
.modal {
background: white;
}
}
/* Not in any layer — wins over everything in layers */
.special-page .modal {
background: oklch(95% 0.02 260);
}Nhưng điều này cũng có nghĩa là CSS của bên thứ ba không hỗ trợ layer có thể ghi đè toàn bộ hệ thống của bạn. Bọc style bên thứ ba trong một layer để kiểm soát chúng:
@layer third-party {
@import url("some-library.css");
}Selector :has(): Selector Cha Mà Chúng Ta Luôn Mong Muốn#
Trong hàng chục năm, lập trình viên đã yêu cầu một selector cha. "Tôi muốn style phần tử cha dựa trên con của nó." Câu trả lời luôn là "CSS không thể làm điều đó" kèm theo một giải pháp JavaScript. :has() thay đổi điều này hoàn toàn, và hóa ra nó còn mạnh hơn những gì chúng ta yêu cầu.
Chọn Phần Tử Cha Cơ Bản#
/* Style a form group when its input has focus */
.form-group:has(input:focus) {
border-color: oklch(55% 0.2 260);
box-shadow: 0 0 0 3px oklch(55% 0.2 260 / 0.15);
}
/* Style a card differently when it contains an image */
.card:has(img) {
padding-top: 0;
}
.card:has(img) .card-content {
padding: 1.5rem;
}
/* A card without an image gets different treatment */
.card:not(:has(img)) {
border-left: 4px solid oklch(55% 0.2 260);
}Trạng Thái Validation Form Không Cần JavaScript#
Đây là nơi :has() trở nên thực sự thú vị. Kết hợp với các pseudo-class validation HTML, bạn có thể xây dựng giao diện form phản ứng với trạng thái hợp lệ mà không cần JavaScript:
/* The field wrapper reacts to its input's validity */
.field:has(input:invalid:not(:placeholder-shown)) {
--field-color: oklch(55% 0.25 25);
}
.field:has(input:valid:not(:placeholder-shown)) {
--field-color: oklch(55% 0.2 145);
}
.field {
--field-color: oklch(70% 0 0);
border: 2px solid var(--field-color);
border-radius: 0.5rem;
padding: 0.75rem;
transition: border-color 0.2s;
}
.field label {
color: var(--field-color);
font-size: 0.875rem;
font-weight: 500;
}
.field .error-message {
display: none;
color: oklch(55% 0.25 25);
font-size: 0.8rem;
margin-top: 0.25rem;
}
.field:has(input:invalid:not(:placeholder-shown)) .error-message {
display: block;
}Phần :not(:placeholder-shown) rất quan trọng — nó ngăn style validation xuất hiện trên các trường rỗng chưa được chạm vào.
Bố Cục Dựa Trên Số Lượng Con#
Pattern này cực kỳ hữu ích và thực sự không thể thực hiện trước :has():
/* Adjust grid columns based on how many items exist */
.grid-auto:has(> :nth-child(4)) {
grid-template-columns: repeat(2, 1fr);
}
.grid-auto:has(> :nth-child(7)) {
grid-template-columns: repeat(3, 1fr);
}
.grid-auto:has(> :nth-child(13)) {
grid-template-columns: repeat(4, 1fr);
}Grid thay đổi số cột dựa trên số phần tử con. Không JavaScript. Không ResizeObserver. Không toggle class.
Phát Hiện Sidebar#
Một trong những pattern yêu thích của tôi — thay đổi bố cục dựa trên việc sidebar có tồn tại hay không:
.page-layout {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
/* If the layout contains a sidebar, switch to two columns */
.page-layout:has(.sidebar) {
grid-template-columns: 1fr 300px;
}
/* Adjust main content width when sidebar is present */
.page-layout:has(.sidebar) .main-content {
max-width: 65ch;
}Thêm component sidebar vào DOM, bố cục tự điều chỉnh. Xóa nó, bố cục điều chỉnh lại. CSS là nguồn sự thật, không phải biến state.
Kết Hợp :has() Với Các Selector Khác#
:has() kết hợp tuyệt đẹp với mọi thứ khác:
/* Style an article only when it has a specific class of figure */
article:has(figure.full-bleed) {
overflow: visible;
}
/* A navigation that changes when it contains a search input */
nav:has(input[type="search"]:focus) {
background: oklch(98% 0 0);
box-shadow: 0 2px 8px oklch(0% 0 0 / 0.08);
}
/* Enable dark-mode at the component level */
.theme-switch:has(input:checked) ~ main {
color-scheme: dark;
background: oklch(15% 0 0);
color: oklch(90% 0 0);
}Hỗ trợ trình duyệt hiện tại rất tốt. Mọi trình duyệt hiện đại đều hỗ trợ :has() từ đầu năm 2024. Không có lý do gì để không sử dụng nó.
CSS Nesting: Tích Hợp Sẵn, Cuối Cùng Cũng Ổn Định#
Tôi sẽ không phóng đại tính năng này. CSS nesting rất tiện. Nó không mang tính cách mạng như container queries hay :has(). Nhưng nó loại bỏ một trong những lý do cuối cùng để dùng Sass, và điều đó quan trọng.
Cú Pháp#
.card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
.title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.description {
color: oklch(40% 0 0);
line-height: 1.7;
}
&:hover {
box-shadow: 0 4px 12px oklch(0% 0 0 / 0.1);
}
&.featured {
border: 2px solid oklch(60% 0.2 260);
}
}Đây là CSS hợp lệ. Không cần bước build. Không cần preprocessor. Không cần plugin PostCSS. Trình duyệt xử lý nguyên bản.
Khác Biệt So Với Sass#
Có một vài khác biệt cú pháp đáng biết:
/* CSS Nesting — works now in all browsers */
.parent {
/* Direct class/element nesting works without & */
.child {
color: red;
}
/* & is required for pseudo-classes and compound selectors */
&:hover {
background: blue;
}
&.active {
font-weight: bold;
}
/* Nested media queries — this is great */
@media (width >= 768px) {
flex-direction: row;
}
}Trong các bản triển khai ban đầu, bạn cần & trước selector phần tử (như & p thay vì chỉ p). Hạn chế đó đã được nới lỏng. Tính đến 2025, tất cả trình duyệt chính đều hỗ trợ nesting phần tử trực tiếp: .parent { p { ... } } hoạt động bình thường.
Media Queries Lồng Nhau#
Đây là tính năng killer của CSS nesting, theo ý kiến của tôi. Không phải nesting selector — mà là khả năng đặt media queries bên trong một khối rule:
.hero {
padding: 2rem;
font-size: 1rem;
@media (width >= 768px) {
padding: 4rem;
font-size: 1.25rem;
}
@media (width >= 1200px) {
padding: 6rem;
font-size: 1.5rem;
}
}So sánh với cách cũ khi style .hero của bạn nằm rải rác qua ba khối @media khác nhau, có thể cách nhau hàng trăm dòng. Nesting giữ hành vi responsive cùng vị trí với component. Khả năng đọc được cải thiện đáng kể.
Đừng Lồng Quá Sâu#
Một lời cảnh báo: chỉ vì bạn có thể lồng sáu cấp sâu không có nghĩa là bạn nên làm vậy. Lời khuyên tương tự từ Sass áp dụng ở đây. Nếu nesting tạo ra selector như .page .section .card .content .text .highlight, bạn đã tạo ra một con quái vật specificity và cơn ác mộng bảo trì. Hai hoặc ba cấp là điểm hợp lý nhất.
/* Good — two levels */
.nav {
.link {
color: inherit;
&:hover {
color: oklch(55% 0.2 260);
}
}
}
/* Bad — specificity nightmare */
.page {
.layout {
.sidebar {
.nav {
.list {
.item {
.link {
color: red; /* good luck overriding this */
}
}
}
}
}
}
}Hàm Màu: oklch() và color-mix() Thay Đổi Mọi Thứ#
hsl() đã hoạt động tốt. Nó trực quan hơn rgb(). Nhưng nó có một lỗ hổng căn bản: nó không đồng nhất về mặt tri giác. Một hsl(60, 100%, 50%) (vàng) trông sáng hơn đáng kể so với hsl(240, 100%, 50%) (xanh dương) đối với mắt người, mặc dù chúng có cùng giá trị lightness.
Tại Sao oklch() Thắng#
oklch() đồng nhất về mặt tri giác. Các giá trị lightness bằng nhau trông sáng như nhau. Điều này cực kỳ quan trọng khi tạo bảng màu, tạo theme, và đảm bảo contrast truy cập được:
:root {
/* oklch(lightness chroma hue) */
--color-primary: oklch(55% 0.2 260); /* blue */
--color-secondary: oklch(55% 0.2 330); /* purple */
--color-success: oklch(55% 0.2 145); /* green */
--color-danger: oklch(55% 0.25 25); /* red */
--color-warning: oklch(75% 0.18 85); /* yellow — higher L for equal perception */
/* These all look equally "medium" to the human eye */
/* With HSL, you'd need different lightness values for each hue */
}Ba giá trị rất trực quan khi bạn hiểu chúng:
- Lightness (0% đến 100%): Sáng hay tối. 0% là đen, 100% là trắng.
- Chroma (0 đến ~0.37): Mức độ rực rỡ. 0 là xám, cao hơn là bão hòa hơn.
- Hue (0 đến 360): Góc màu. 0/360 là đỏ hồng, 145 là xanh lá, 260 là xanh dương.
color-mix() Cho Màu Dẫn Xuất#
color-mix() cho phép bạn tạo màu từ các màu khác tại runtime. Không cần hàm darken() của Sass. Không cần JavaScript. Chỉ CSS:
:root {
--brand: oklch(55% 0.2 260);
/* Lighten by mixing with white */
--brand-light: color-mix(in oklch, var(--brand) 30%, white);
/* Darken by mixing with black */
--brand-dark: color-mix(in oklch, var(--brand) 70%, black);
/* Create a subtle background */
--brand-bg: color-mix(in oklch, var(--brand) 8%, white);
/* Semi-transparent version */
--brand-overlay: color-mix(in oklch, var(--brand) 50%, transparent);
}
.button {
background: var(--brand);
color: white;
&:hover {
background: var(--brand-dark);
}
&:active {
background: color-mix(in oklch, var(--brand) 60%, black);
}
}
.button.secondary {
background: var(--brand-bg);
color: var(--brand-dark);
&:hover {
background: color-mix(in oklch, var(--brand) 15%, white);
}
}Phần in oklch rất quan trọng. Trộn trong srgb cho ra màu trung gian đục. Trộn trong oklch cho kết quả đồng đều về mặt tri giác. Luôn trộn trong oklch.
Xây Dựng Bảng Màu Hoàn Chỉnh Với oklch()#
Đây là cách tôi tạo toàn bộ bảng sắc thái từ một hue duy nhất:
:root {
--hue: 260;
--chroma: 0.2;
--color-50: oklch(97% calc(var(--chroma) * 0.1) var(--hue));
--color-100: oklch(93% calc(var(--chroma) * 0.2) var(--hue));
--color-200: oklch(85% calc(var(--chroma) * 0.4) var(--hue));
--color-300: oklch(75% calc(var(--chroma) * 0.6) var(--hue));
--color-400: oklch(65% calc(var(--chroma) * 0.8) var(--hue));
--color-500: oklch(55% var(--chroma) var(--hue));
--color-600: oklch(48% var(--chroma) var(--hue));
--color-700: oklch(40% calc(var(--chroma) * 0.9) var(--hue));
--color-800: oklch(32% calc(var(--chroma) * 0.8) var(--hue));
--color-900: oklch(25% calc(var(--chroma) * 0.7) var(--hue));
--color-950: oklch(18% calc(var(--chroma) * 0.5) var(--hue));
}Đổi --hue thành 145 và bạn có bảng màu xanh lá. Đổi thành 25 và bạn có đỏ. Các bước lightness đồng đều về mặt tri giác. Chroma giảm dần ở các cực để các sắc thái sáng nhất và tối nhất không bị quá bão hòa. Đây là loại thứ trước đây cần công cụ thiết kế hoặc hàm Sass. Giờ chỉ cần tám dòng CSS.
Animation Điều Khiển Bởi Cuộn: Không Cần JavaScript#
Đây là tính năng khiến tôi xóa nhiều JavaScript nhất. Animation liên kết cuộn — thanh tiến trình, hiệu ứng parallax, animation hiện ra, header sticky với transition — trước đây cần IntersectionObserver, listener sự kiện scroll, hoặc thư viện như GSAP. Giờ đây nó là CSS.
Chỉ Báo Tiến Trình Cuộn#
Thanh "tiến trình đọc" cổ điển ở đầu trang bài viết:
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 3px;
background: oklch(55% 0.2 260);
transform-origin: left;
z-index: 1000;
animation: grow-progress linear both;
animation-timeline: scroll();
}
@keyframes grow-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}Chỉ vậy thôi. Toàn bộ chỉ báo tiến trình đọc. Không JavaScript. Không listener sự kiện cuộn. Không requestAnimationFrame. Không tính toán "phần trăm đã cuộn." Binding animation-timeline: scroll() xử lý mọi thứ.
Hiện Ra Khi Cuộn#
Các phần tử mờ dần khi chúng vào viewport:
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}animation-timeline: view() liên kết animation với tầm nhìn của phần tử trong viewport. animation-range: entry 0% entry 100% nghĩa là animation chạy từ khi phần tử bắt đầu vào viewport cho đến khi nó hoàn toàn hiển thị.
Parallax Không Cần Thư Viện#
.parallax-bg {
animation: parallax linear both;
animation-timeline: scroll();
}
@keyframes parallax {
from {
transform: translateY(-20%);
}
to {
transform: translateY(20%);
}
}
.parallax-section {
overflow: hidden;
position: relative;
}Hình nền di chuyển ở tốc độ khác so với cuộn, tạo hiệu ứng parallax. Mượt mà, hiệu năng cao (trình duyệt có thể composite trên GPU), và không cần JavaScript.
Timeline Cuộn Có Tên#
Để kiểm soát nhiều hơn, bạn có thể đặt tên timeline cuộn và tham chiếu từ các phần tử khác:
.scroller {
overflow-y: auto;
scroll-timeline-name: --my-scroller;
scroll-timeline-axis: block;
}
/* An element anywhere in the DOM can reference this timeline */
.indicator {
animation: progress linear both;
animation-timeline: --my-scroller;
}
@keyframes progress {
from { width: 0%; }
to { width: 100%; }
}Điều này hoạt động ngay cả khi indicator và scroller không phải cha/con. Bất kỳ phần tử nào cũng có thể liên kết với bất kỳ timeline cuộn có tên nào. Điều này rất mạnh cho giao diện dashboard nơi một panel cuộn được điều khiển indicator trong header cố định.
Anchor Positioning: Tooltip và Popover Đúng Cách#
Trước anchor positioning, kết nối tooltip với phần tử kích hoạt yêu cầu JavaScript. Bạn phải tính toán vị trí với getBoundingClientRect(), xử lý offset cuộn, quản lý va chạm viewport, và tính toán lại khi resize. Các thư viện như Popper.js (nay là Floating UI) tồn tại chính vì điều này quá khó để làm đúng.
CSS anchor positioning biến điều này thành khai báo:
.trigger {
anchor-name: --my-trigger;
}
.tooltip {
position: fixed;
position-anchor: --my-trigger;
/* Position the tooltip's top-center at the trigger's bottom-center */
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
/* Fallback positioning if it overflows the viewport */
position-try-fallbacks: flip-block, flip-inline;
}Xử Lý Va Chạm Viewport Tự Động#
Thuộc tính position-try-fallbacks là phần mà trước đây sẽ cần 200 dòng JavaScript. Nó nói với trình duyệt: "nếu tooltip tràn viewport ở dưới, lật nó lên trên. Nếu nó tràn bên phải, lật sang trái."
.dropdown-menu {
position: fixed;
position-anchor: --menu-button;
/* Default: below the button, aligned to the left edge */
top: anchor(bottom);
left: anchor(left);
/* If it doesn't fit below, try above. If it doesn't fit left-aligned, try right-aligned */
position-try-fallbacks: flip-block, flip-inline;
/* Add a gap between the anchor and the dropdown */
margin-top: 4px;
}Fallback Vị Trí Có Tên#
Để kiểm soát nhiều hơn các vị trí fallback, bạn có thể định nghĩa các tùy chọn try tùy chỉnh:
@position-try --above-right {
bottom: anchor(top);
right: anchor(right);
top: auto;
left: auto;
margin-top: 0;
margin-bottom: 4px;
}
@position-try --left-center {
right: anchor(left);
top: anchor(center);
left: auto;
bottom: auto;
translate: 0 -50%;
margin-top: 0;
margin-right: 4px;
}
.tooltip {
position: fixed;
position-anchor: --trigger;
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
margin-top: 4px;
position-try-fallbacks: --above-right, --left-center;
}Trình duyệt thử từng fallback theo thứ tự cho đến khi tìm được vị trí giữ phần tử trong viewport. Đây là loại suy luận không gian mà thực sự rất đau đớn trong JavaScript.
Neo Với Popover API#
Anchor positioning kết hợp hoàn hảo với Popover API mới:
<button popovertarget="my-popover" style="anchor-name: --btn">Settings</button>
<div popover id="my-popover" style="
position-anchor: --btn;
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
margin-top: 8px;
">
Popover content here
</div>Không cần JavaScript để hiển thị/ẩn (Popover API xử lý điều đó). Không cần JavaScript cho định vị (anchor positioning xử lý điều đó). Không cần JavaScript cho hành vi light-dismiss (Popover API cũng xử lý điều đó). Toàn bộ pattern tooltip/popover/dropdown — một pattern đã hỗ trợ cả package npm — giờ là HTML và CSS.
CSS Grid Subgrid: Căn Chỉnh Lồng Nhau Thực Sự Hoạt Động#
Grid rất mạnh, nhưng nó có một hạn chế khó chịu: grid con không thể căn chỉnh các phần tử với grid cha. Nếu bạn có một hàng card và muốn tiêu đề, nội dung, và chân trang của mỗi card căn chỉnh qua các card, bạn không may mắn rồi. Grid nội bộ của mỗi card là độc lập.
Subgrid sửa điều này.
Vấn Đề Căn Chỉnh Card#
/* Parent grid */
.card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
/* Each card becomes a subgrid, aligning rows to the parent */
.card {
display: grid;
grid-row: span 3; /* Card spans 3 rows in the parent */
grid-template-rows: subgrid;
gap: 0; /* Card controls its own internal gap */
}
.card-title {
font-weight: 600;
padding: 1rem 1rem 0.5rem;
}
.card-body {
padding: 0 1rem;
color: oklch(40% 0 0);
}
.card-footer {
padding: 0.5rem 1rem 1rem;
margin-top: auto;
border-top: 1px solid oklch(90% 0 0);
}Giờ các tiêu đề trên cả ba card căn chỉnh trên cùng một hàng. Các phần thân căn chỉnh. Các chân trang căn chỉnh. Ngay cả khi một card có tiêu đề hai dòng và card khác có tiêu đề một dòng, việc căn chỉnh được duy trì bởi grid cha.
Trước Subgrid: Các Giải Pháp Tạm#
Không có subgrid, việc đạt được căn chỉnh này yêu cầu:
- Chiều cao cố định (dễ vỡ, hỏng với nội dung động)
- Đo lường bằng JavaScript (chậm, nhấp nháy lệch)
- Bỏ cuộc và chấp nhận lệch (phổ biến, xấu)
/* The old hack — fragile and breaks with dynamic content */
.card-title {
min-height: 3rem; /* pray that no title exceeds this */
}
.card-body {
min-height: 8rem; /* even more prayer */
}Subgrid khiến giải pháp tạm trở nên không cần thiết. Grid cha phân phối chiều cao hàng dựa trên nội dung cao nhất trong mỗi hàng trên tất cả card.
Column Subgrid Cho Bố Cục Form#
Subgrid hoạt động trên cột nữa, rất hoàn hảo cho bố cục form:
.form {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem 1rem;
}
.form-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
.form-row label {
grid-column: 1;
text-align: right;
font-weight: 500;
}
.form-row input {
grid-column: 2;
padding: 0.5rem;
border: 1px solid oklch(80% 0 0);
border-radius: 0.375rem;
}Tất cả label căn chỉnh. Tất cả input căn chỉnh. Cột label tự điều chỉnh kích thước theo label rộng nhất. Không cần hardcode chiều rộng.
View Transitions API: Điều Hướng Giống SPA Không Cần Framework#
Đây là tính năng tham vọng nhất trong danh sách. View Transitions API cho phép bạn tạo animation giữa các điều hướng trang — bao gồm cả điều hướng xuyên tài liệu (click liên kết bình thường trong ứng dụng đa trang). Trang HTML tĩnh của bạn giờ có thể có chuyển trang mượt mà, có animation.
Chuyển Trang Xuyên Tài Liệu#
Để bật view transitions xuyên tài liệu, bạn thêm một rule CSS duy nhất vào cả trang cũ và trang mới:
@view-transition {
navigation: auto;
}Chỉ vậy thôi. Trình duyệt giờ sẽ cross-fade giữa các trang khi điều hướng. Transition mặc định là một opacity fade mượt mà. Không JavaScript. Không framework. Chỉ hai dòng CSS trên mỗi trang.
Tùy Chỉnh Transition#
Bạn có thể tùy chỉnh transition nào xảy ra bằng cách đặt tên cho các phần tử cụ thể:
/* On both pages */
.page-header {
view-transition-name: header;
}
.main-content {
view-transition-name: content;
}
.hero-image {
view-transition-name: hero;
}
/* Transition animations */
::view-transition-old(content) {
animation: slide-out 0.3s ease-in both;
}
::view-transition-new(content) {
animation: slide-in 0.3s ease-out both;
}
::view-transition-old(hero) {
animation: fade-out 0.2s ease-in both;
}
::view-transition-new(hero) {
animation: none; /* The new hero just appears */
}
@keyframes slide-out {
to {
transform: translateX(-100%);
opacity: 0;
}
}
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes fade-out {
to {
opacity: 0;
}
}Khi điều hướng giữa hai trang đều có .hero-image với view-transition-name: hero, trình duyệt tự động tạo animation hình ảnh từ vị trí trên trang cũ đến vị trí trên trang mới. Đó là pattern "shared element transition" từ phát triển mobile, giờ có trong trình duyệt.
View Transitions Cho SPA#
Cho ứng dụng đơn trang (React, Vue, Svelte, v.v.), JavaScript API rất đơn giản:
/* CSS side — define your transition animations */
::view-transition-old(root) {
animation: fade-out 0.2s ease-in;
}
::view-transition-new(root) {
animation: fade-in 0.2s ease-out;
}// JS side — wrap your DOM update in startViewTransition
document.startViewTransition(() => {
// Update the DOM here — React render, innerHTML swap, etc.
updateContent(newPageData);
});Trình duyệt chụp snapshot trạng thái cũ, chạy cập nhật của bạn, chụp snapshot trạng thái mới, và tạo animation giữa chúng. Phần tử có tên nhận transition riêng lẻ; mọi thứ khác nhận crossfade mặc định.
Tôn Trọng Tùy Chọn Người Dùng#
Luôn tôn trọng prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}Những Gì Bạn Có Thể Ngừng Sử Dụng#
Các tính năng này cùng nhau loại bỏ rất nhiều công cụ, thư viện, và pattern mà chúng ta đã dựa vào suốt nhiều năm. Đây là những gì tôi đã bỏ hoặc ngừng sử dụng:
Sass/SCSS Cho Nesting và Biến#
CSS có nesting nguyên bản. CSS có custom properties (và đã có nhiều năm). Hai lý do chính khiến người ta dùng Sass giờ đã có trong ngôn ngữ. Nếu bạn vẫn dùng Sass chỉ cho $variables và nesting, bạn có thể dừng.
/* Before: Sass */
$primary: #3b82f6;
.card {
background: white;
border: 1px solid lighten($primary, 40%);
&:hover {
border-color: $primary;
}
.title {
color: darken($primary, 15%);
}
}
/* After: Native CSS */
.card {
--primary: oklch(55% 0.2 260);
background: white;
border: 1px solid color-mix(in oklch, var(--primary) 20%, white);
&:hover {
border-color: var(--primary);
}
.title {
color: color-mix(in oklch, var(--primary) 70%, black);
}
}Phiên bản CSS mạnh hơn — oklch cho thao tác màu đồng nhất tri giác, color-mix hoạt động tại runtime (Sass lighten chỉ ở compile-time), và custom properties có thể thay đổi động từ JavaScript hoặc media queries.
JavaScript Cho Animation Cuộn#
Xóa code reveal-on-scroll IntersectionObserver. Xóa JavaScript thanh tiến trình cuộn. Xóa handler cuộn parallax. animation-timeline: scroll() và animation-timeline: view() xử lý tất cả những thứ này với hiệu năng tốt hơn (compositor thread, không phải main thread).
// Before: JavaScript you can delete
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));/* After: CSS that replaces all of the above */
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes reveal {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}Popper.js / Floating UI Cho Định Vị#
Nếu bạn đang dùng Floating UI (trước đây là Popper.js) chỉ cho định vị tooltip/popover cơ bản, CSS anchor positioning thay thế nó. Bạn mất một số tính năng nâng cao (phần tử ảo, middleware tùy chỉnh), nhưng cho 90% trường hợp sử dụng — "đặt popover này gần nút đó và giữ nó trong viewport" — CSS giờ làm nguyên bản.
Hack Căn Giữa margin: auto#
Điều này không mới, nhưng tôi vẫn thấy margin: 0 auto ở khắp nơi. Các công cụ bố cục hiện đại khiến nó không cần thiết trong hầu hết trường hợp:
/* Old way */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
/* Better: CSS Grid or Flexbox */
.page {
display: grid;
place-items: center;
}
/* Or: container queries make the container itself responsive */
.content {
container-type: inline-size;
width: min(100% - 2rem, 1200px);
margin-inline: auto; /* OK, still auto, but margin-inline is clearer */
}margin: 0 auto vẫn hoạt động tốt. Nhưng nếu bạn thấy mình dùng nó cho căn giữa dọc hoặc căn chỉnh phức tạp, hãy dùng flexbox hoặc grid thay thế.
Media Queries Cho Responsive Cấp Component#
Đây là điều quan trọng nhất. Nếu bạn đang viết @media queries để làm component responsive, bạn có lẽ đang làm sai rồi. Container queries nên là mặc định cho responsive cấp component. Dành @media queries cho các quyết định cấp trang: thay đổi bố cục, pattern điều hướng, style in.
/* Before: Media query for component styling (wrong scope) */
@media (min-width: 768px) {
.product-card {
flex-direction: row;
}
}
/* After: Container query (right scope) */
.product-card-wrapper {
container-type: inline-size;
}
@container (min-width: 400px) {
.product-card {
flex-direction: row;
}
}Bức Tranh Toàn Cảnh#
Các tính năng này không tồn tại riêng lẻ. Chúng kết hợp với nhau. Container queries + :has() + nesting + oklch() + layers — sử dụng cùng nhau — mang đến trải nghiệm viết CSS mà năm năm trước sẽ không thể nhận ra:
@layer components {
.card-wrapper {
container-type: inline-size;
}
.card {
--accent: oklch(55% 0.2 260);
display: grid;
gap: 1rem;
padding: 1.5rem;
border-radius: 0.5rem;
background: white;
border: 1px solid oklch(90% 0 0);
&:has(img) {
padding-top: 0;
img {
border-radius: 0.5rem 0.5rem 0 0;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
}
&:has(.badge.new) {
border-color: var(--accent);
}
.title {
font-size: 1.125rem;
font-weight: 600;
color: oklch(20% 0 0);
text-wrap: balance;
}
.meta {
font-size: 0.875rem;
color: oklch(50% 0 0);
}
@container (min-width: 500px) {
grid-template-columns: 200px 1fr;
&:has(img) {
padding: 0;
img {
border-radius: 0.5rem 0 0 0.5rem;
height: 100%;
aspect-ratio: auto;
}
}
.content {
padding: 1.5rem;
}
}
}
}Khối đơn lẻ đó xử lý:
- Bố cục responsive cấp component (container queries)
- Style có điều kiện dựa trên nội dung (
:has()) - Tổ chức selector sạch sẽ (nesting)
- Màu đồng nhất tri giác (
oklch) - Quản lý specificity dự đoán được (
@layer)
Không preprocessor. Không runtime CSS-in-JS. Không utility class cho điều này. Chỉ CSS, làm những gì CSS đáng lẽ phải làm từ lâu.
Nền tảng đã bắt kịp. Cuối cùng. Và nó đáng để chờ đợi.