Vai al contenuto
·24 min di lettura

CSS moderno nel 2026: le funzionalità che hanno cambiato il mio modo di scrivere stili

Container query, CSS layer, :has(), color-mix(), nesting, animazioni scroll-driven e anchor positioning. Le funzionalità CSS che mi hanno fatto smettere di ricorrere a JavaScript.

Condividi:X / TwitterLinkedIn

Ho cancellato il mio ultimo file Sass sei mesi fa. Non perché stessi facendo una dichiarazione di principio. Perché genuinamente non ne avevo più bisogno.

Per oltre un decennio, il CSS è stato il linguaggio per cui ci scusavamo. Avevamo bisogno dei preprocessori per il nesting e le variabili. Avevamo bisogno di JavaScript per il dimensionamento basato sul container, le animazioni legate allo scroll, la selezione del genitore e metà dei pattern di layout che i designer ci consegnavano. Abbiamo costruito interi sistemi runtime — librerie CSS-in-JS, framework a utility, catene di plugin PostCSS — per compensare ciò che il linguaggio non sapeva fare nativamente.

Quell'era è finita. Non "quasi finita." Non "ci stiamo arrivando." Finita.

Le funzionalità approdate nei browser tra il 2024 e il 2026 non hanno solo aggiunto comodità. Hanno cambiato il modello mentale. Il CSS non è più un linguaggio di stile contro cui combatti. È un linguaggio di stile che pensa davvero ai componenti, alla gestione della specificità, al design responsive a livello di elemento e alle animazioni senza runtime.

Ecco cosa è cambiato, perché è importante, e cosa puoi smettere di fare grazie a queste novità.

Container Query: la fine di "quanto è largo il viewport?"#

Questa è quella che ha alterato fondamentalmente il mio modo di pensare al design responsive. Per vent'anni, avevamo le media query. Le media query chiedono: "quanto è largo il viewport?" Le container query chiedono: "quanto è largo il container in cui vive questo componente?"

Quella distinzione sembra sottile. Non lo è. È la differenza tra componenti che funzionano solo in un contesto di layout e componenti che funzionano ovunque.

Il problema che è esistito per due decenni#

Considera un componente card. In una sidebar, dovrebbe impilarsi verticalmente con un'immagine piccola. In un'area di contenuto principale, dovrebbe diventare orizzontale con un'immagine più grande. In una sezione hero a tutta larghezza, dovrebbe essere qualcosa di completamente diverso.

Con le media query, scriveresti qualcosa del genere:

css
/* Il vecchio modo: accoppiare gli stili del componente al layout della pagina */
.card {
  display: flex;
  flex-direction: column;
}
 
@media (min-width: 768px) {
  .sidebar .card {
    /* Ancora verticale nella sidebar */
  }
 
  .main-content .card {
    flex-direction: row;
    /* Orizzontale nell'area principale */
  }
}
 
@media (min-width: 1200px) {
  .hero .card {
    /* Ancora un altro layout */
  }
}

La card conosce .sidebar, .main-content e .hero. Conosce la pagina. Non è più un componente — è un frammento consapevole della pagina. Spostala in una pagina diversa e tutto si rompe.

Le container query risolvono completamente il problema#

css
/* L'approccio container query: il componente conosce solo se stesso */
.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;
  }
}

La card non sa dove vive. Non le importa. Mettila in una sidebar da 300px ed è verticale. Mettila in un'area principale da 700px ed è orizzontale. Inseriscila in una sezione a tutta larghezza e si adatta. Zero conoscenza del layout della pagina richiesta.

inline-size vs size#

Quasi sempre vorrai container-type: inline-size. Questo abilita le query sull'asse inline (larghezza nelle modalità di scrittura orizzontale). Usare container-type: size abilita query su entrambi gli assi inline e block, ma richiede che il container abbia dimensioni esplicite in entrambe le dimensioni, il che rompe il flusso normale del documento nella maggior parte dei casi.

css
/* Questo è ciò che vuoi nel 99% dei casi */
.wrapper {
  container-type: inline-size;
}
 
/* Questo richiede un'altezza esplicita — raramente ciò che vuoi */
.wrapper-both {
  container-type: size;
  height: 500px; /* necessario, altrimenti collassa */
}

Container con nome per contesti annidati#

Quando annidi i container, dare un nome diventa essenziale:

css
.page-layout {
  container-type: inline-size;
  container-name: layout;
}
 
.sidebar {
  container-type: inline-size;
  container-name: sidebar;
}
 
/* Targetizza la sidebar specificamente, non l'antenato più vicino */
@container sidebar (max-width: 250px) {
  .nav-item {
    font-size: 0.875rem;
    padding: 0.25rem;
  }
}
 
/* Targetizza il layout della pagina */
@container layout (min-width: 1200px) {
  .page-header {
    font-size: 2.5rem;
  }
}

Senza nomi, @container interroga l'antenato più vicino con contenimento. Con l'annidamento, spesso non è il container che vuoi. Dagli un nome. Sempre.

Container query unit#

Questa è sottovalutata. Le container query unit (cqi, cqb, cqw, cqh) ti permettono di dimensionare le cose relativamente al container, non al viewport:

css
@container (min-width: 400px) {
  .card-title {
    font-size: clamp(1rem, 4cqi, 2rem);
  }
}

4cqi è il 4% della dimensione inline del container. Il titolo scala con il container, non con la finestra. Ecco cosa avrebbe dovuto essere la tipografia fluida fin dall'inizio.

CSS Layer: la guerra della specificità è finita#

Se le container query hanno cambiato il mio modo di pensare al design responsive, @layer ha cambiato il mio modo di pensare all'architettura CSS. Per la prima volta, abbiamo un modo sano e dichiarativo per gestire la specificità in un intero progetto.

Il problema#

La specificità CSS è un sistema a punti a cui non importa delle tue intenzioni. Una classe utility con .text-red perde contro .card .title perché quest'ultima ha specificità più alta. La soluzione era sempre la stessa: rendere i selettori più specifici, aggiungere !important, o ristrutturare tutto.

Abbiamo costruito intere metodologie (BEM, SMACSS, ITCSS) e toolchain solo per evitare conflitti di specificità. Tutto ciò era un workaround per una funzionalità mancante nel linguaggio.

Ordinamento dei layer#

@layer ti permette di dichiarare l'ordine in cui i gruppi di stili vengono considerati, indipendentemente dalla specificità al loro interno:

css
/* Dichiara l'ordine dei layer — questa singola riga controlla tutto */
@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);
  }
}

Anche se .card .title ha specificità più alta di .text-red, l'utility vince perché il layer utilities è dichiarato dopo components. Nessun !important. Nessun hack di specificità. L'ordine dei layer è la parola finale.

Come Tailwind CSS v4 usa i layer#

Tailwind v4 si basa pesantemente su @layer. Quando scrivi @import "tailwindcss", ottieni:

css
@layer theme, base, components, utilities;

Ogni utility Tailwind vive nel layer utilities. I tuoi stili componente personalizzati vanno in components. Ecco perché una classe text-red-500 può sovrascrivere il colore di un componente senza bisogno di !important — si trova in un layer successivo.

Se stai costruendo il tuo design system senza Tailwind, ruba questa architettura. È quella giusta:

css
@layer reset, tokens, base, layouts, components, utilities, overrides;

Sette layer sono più che sufficienti. Non ne ho mai avuti bisogno di più.

Gli stili senza layer vincono su tutto#

Un'insidia: gli stili che non sono in nessun layer hanno la priorità più alta. Questo in realtà è utile — significa che i tuoi override una tantum specifici per pagina vincono automaticamente:

css
@layer components {
  .modal {
    background: white;
  }
}
 
/* Non è in nessun layer — vince su tutto ciò che è nei layer */
.special-page .modal {
  background: oklch(95% 0.02 260);
}

Ma significa anche che CSS di terze parti non consapevole dei layer può sovrascrivere il tuo intero sistema. Avvolgi gli stili di terze parti in un layer per controllarli:

css
@layer third-party {
  @import url("some-library.css");
}

Il selettore :has(): il selettore del genitore che abbiamo sempre voluto#

Per letteralmente decenni, gli sviluppatori hanno chiesto un selettore del genitore. "Voglio stilizzare un genitore in base ai suoi figli." La risposta era sempre "il CSS non può farlo" seguita da un workaround JavaScript. :has() cambia completamente la situazione, e si scopre che è ancora più potente di ciò che avevamo chiesto.

Selezione base del genitore#

css
/* Stila un form group quando il suo input ha il 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);
}
 
/* Stila una card diversamente quando contiene un'immagine */
.card:has(img) {
  padding-top: 0;
}
 
.card:has(img) .card-content {
  padding: 1.5rem;
}
 
/* Una card senza immagine riceve un trattamento diverso */
.card:not(:has(img)) {
  border-left: 4px solid oklch(55% 0.2 260);
}

Stati di validazione dei form senza JavaScript#

Qui :has() diventa davvero entusiasmante. Combinato con le pseudo-classi di validazione HTML, puoi costruire UI per form che rispondono allo stato di validità con zero JavaScript:

css
/* Il wrapper del campo reagisce alla validità del suo input */
.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;
}

La parte :not(:placeholder-shown) è cruciale — impedisce che gli stili di validazione appaiano su campi vuoti che non sono ancora stati toccati.

Layout basato sul numero di figli#

Questo pattern è assurdamente utile ed era genuinamente impossibile prima di :has():

css
/* Regola le colonne della griglia in base a quanti elementi esistono */
.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);
}

La griglia cambia il suo conteggio di colonne in base a quanti figli ha. Nessun JavaScript. Nessun ResizeObserver. Nessun toggling di classi.

Rilevamento della sidebar#

Uno dei miei pattern preferiti — cambiare il layout in base alla presenza di una sidebar:

css
.page-layout {
  display: grid;
  grid-template-columns: 1fr;
  gap: 2rem;
}
 
/* Se il layout contiene una sidebar, passa a due colonne */
.page-layout:has(.sidebar) {
  grid-template-columns: 1fr 300px;
}
 
/* Regola la larghezza del contenuto principale quando è presente la sidebar */
.page-layout:has(.sidebar) .main-content {
  max-width: 65ch;
}

Aggiungi un componente sidebar al DOM, il layout si adatta. Rimuovilo, si riadatta. Il CSS è la source of truth, non una variabile di stato.

Combinare :has() con altri selettori#

:has() si compone magnificamente con tutto il resto:

css
/* Stila un articolo solo quando ha un tipo specifico di figure */
article:has(figure.full-bleed) {
  overflow: visible;
}
 
/* Una navigazione che cambia quando contiene un input di ricerca */
nav:has(input[type="search"]:focus) {
  background: oklch(98% 0 0);
  box-shadow: 0 2px 8px oklch(0% 0 0 / 0.08);
}
 
/* Abilita la dark mode a livello di componente */
.theme-switch:has(input:checked) ~ main {
  color-scheme: dark;
  background: oklch(15% 0 0);
  color: oklch(90% 0 0);
}

Il supporto browser è eccellente ora. Tutti i browser moderni supportano :has() dall'inizio del 2024. Non c'è motivo per non usarlo.

CSS Nesting: nativo, finalmente stabile#

Non esagererò su questo. Il CSS nesting è carino. Non è rivoluzionario come le container query o :has(). Ma rimuove una delle ultime ragioni per usare Sass, e questo conta.

La sintassi#

css
.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);
  }
}

Questo è CSS valido. Nessun build step. Nessun preprocessore. Nessun plugin PostCSS. Il browser lo gestisce nativamente.

Differenze da Sass#

Ci sono alcune differenze di sintassi che vale la pena conoscere:

css
/* CSS Nesting — funziona ora in tutti i browser */
.parent {
  /* Il nesting diretto di classi/elementi funziona senza & */
  .child {
    color: red;
  }
 
  /* & è richiesto per le pseudo-classi e i selettori composti */
  &:hover {
    background: blue;
  }
 
  &.active {
    font-weight: bold;
  }
 
  /* Media query annidate — questo è fantastico */
  @media (width >= 768px) {
    flex-direction: row;
  }
}

Nelle prime implementazioni, serviva & prima dei selettori di elemento (come & p invece di solo p). Quella restrizione è stata allentata. Dal 2025, tutti i browser principali supportano il nesting bare di elementi: .parent { p { ... } } funziona benissimo.

Media query annidate#

Questa è la killer feature del CSS nesting, a mio parere. Non il nesting dei selettori — la possibilità di mettere le media query dentro un blocco di regole:

css
.hero {
  padding: 2rem;
  font-size: 1rem;
 
  @media (width >= 768px) {
    padding: 4rem;
    font-size: 1.25rem;
  }
 
  @media (width >= 1200px) {
    padding: 6rem;
    font-size: 1.5rem;
  }
}

Confronta questo con il vecchio modo in cui i tuoi stili .hero erano sparsi in tre diversi blocchi @media, potenzialmente centinaia di righe distanti. Il nesting mantiene il comportamento responsive co-localizzato con il componente. La leggibilità migliora drasticamente.

Non annidare troppo#

Un avvertimento: solo perché puoi annidare sei livelli in profondità non significa che dovresti. Lo stesso consiglio di Sass si applica qui. Se il tuo nesting crea selettori come .page .section .card .content .text .highlight, hai creato un mostro di specificità e un incubo di manutenzione. Due o tre livelli è il punto ideale.

css
/* Buono — due livelli */
.nav {
  .link {
    color: inherit;
 
    &:hover {
      color: oklch(55% 0.2 260);
    }
  }
}
 
/* Cattivo — incubo di specificità */
.page {
  .layout {
    .sidebar {
      .nav {
        .list {
          .item {
            .link {
              color: red; /* buona fortuna a sovrascriverlo */
            }
          }
        }
      }
    }
  }
}

Funzioni colore: oklch() e color-mix() cambiano tutto#

hsl() ha avuto una bella corsa. Era più intuitivo di rgb(). Ma ha un difetto fondamentale: non è percettivamente uniforme. Un hsl(60, 100%, 50%) (giallo) appare drammaticamente più chiaro all'occhio umano di hsl(240, 100%, 50%) (blu), anche se hanno lo stesso valore di luminosità.

Perché oklch() vince#

oklch() è percettivamente uniforme. Valori di luminosità uguali appaiono ugualmente luminosi. Questo conta enormemente quando si generano palette di colori, si creano temi e si garantisce un contrasto accessibile:

css
:root {
  /* oklch(luminosità croma tinta) */
  --color-primary: oklch(55% 0.2 260);     /* blu */
  --color-secondary: oklch(55% 0.2 330);   /* viola */
  --color-success: oklch(55% 0.2 145);     /* verde */
  --color-danger: oklch(55% 0.25 25);      /* rosso */
  --color-warning: oklch(75% 0.18 85);     /* giallo — L più alto per percezione uguale */
 
  /* Questi appaiono tutti ugualmente "medi" all'occhio umano */
  /* Con HSL, avresti bisogno di valori di luminosità diversi per ogni tinta */
}

I tre valori sono intuitivi una volta compresi:

  • Luminosità (0% a 100%): Quanto chiaro o scuro. 0% è nero, 100% è bianco.
  • Croma (0 a ~0.37): Quanto vivido. 0 è grigio, più alto è più saturo.
  • Tinta (0 a 360): L'angolo del colore. 0/360 è rosso rosato, 145 è verde, 260 è blu.

color-mix() per colori derivati#

color-mix() ti permette di creare colori da altri colori a runtime. Nessuna funzione darken() di Sass. Nessun JavaScript. Solo CSS:

css
:root {
  --brand: oklch(55% 0.2 260);
 
  /* Schiarisci mescolando con il bianco */
  --brand-light: color-mix(in oklch, var(--brand) 30%, white);
 
  /* Scurisci mescolando con il nero */
  --brand-dark: color-mix(in oklch, var(--brand) 70%, black);
 
  /* Crea uno sfondo sottile */
  --brand-bg: color-mix(in oklch, var(--brand) 8%, white);
 
  /* Versione semi-trasparente */
  --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);
  }
}

La parte in oklch conta. Mescolare in srgb produce colori intermedi fangosi. Mescolare in oklch produce risultati percettivamente uniformi. Mescola sempre in oklch.

Costruire una palette completa con oklch()#

Ecco come genero un'intera palette di sfumature da una singola tinta:

css
: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));
}

Cambia --hue a 145 e hai una palette verde. Cambialo a 25 e hai rosso. I passaggi di luminosità sono percettivamente uniformi. Il croma si assottiglia agli estremi così le sfumature più chiare e più scure non sono sovrasaturate. Questo è il tipo di cosa che richiedeva uno strumento di design o una funzione Sass. Ora sono otto righe di CSS.

Animazioni scroll-driven: niente JavaScript richiesto#

Questa è la funzionalità che mi ha fatto cancellare più JavaScript. Le animazioni legate allo scroll — barre di progresso, effetti parallax, animazioni di comparsa, header sticky con transizioni — richiedevano IntersectionObserver, listener di eventi scroll o una libreria come GSAP. Ora è CSS.

Indicatore di progresso dello scroll#

La classica "barra di progresso della lettura" in cima a una pagina articolo:

css
.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);
  }
}

Questo è tutto. L'intero indicatore di progresso della lettura. Nessun JavaScript. Nessun scroll event listener. Nessun requestAnimationFrame. Nessun calcolo di "percentuale scrollata". Il binding animation-timeline: scroll() fa tutto.

Comparsa allo scroll#

Elementi che appaiono in dissolvenza quando entrano nel viewport:

css
.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() lega l'animazione alla visibilità dell'elemento nel viewport. animation-range: entry 0% entry 100% significa che l'animazione parte dal momento in cui l'elemento inizia a entrare nel viewport fino a quando è completamente visibile.

Parallax senza librerie#

css
.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;
}

L'immagine di sfondo si muove a una velocità diversa rispetto allo scroll, creando un effetto parallax. Fluido, performante (il browser può fare il composite sulla GPU), e zero JavaScript.

Timeline di scroll con nome#

Per un controllo maggiore, puoi dare un nome alle timeline di scroll e referenziarle da altri elementi:

css
.scroller {
  overflow-y: auto;
  scroll-timeline-name: --my-scroller;
  scroll-timeline-axis: block;
}
 
/* Un elemento ovunque nel DOM può referenziare questa timeline */
.indicator {
  animation: progress linear both;
  animation-timeline: --my-scroller;
}
 
@keyframes progress {
  from { width: 0%; }
  to { width: 100%; }
}

Questo funziona anche quando l'indicatore e lo scroller non sono genitore/figlio. Qualsiasi elemento può collegarsi a qualsiasi timeline di scroll con nome. Questo è potente per le UI dashboard dove un pannello scrollabile guida un indicatore in un header fisso.

Anchor Positioning: tooltip e popover fatti bene#

Prima dell'anchor positioning, collegare un tooltip al suo elemento trigger richiedeva JavaScript. Dovevi calcolare le posizioni con getBoundingClientRect(), gestire gli offset dello scroll, gestire le collisioni con il viewport e ricalcolare al resize. Librerie come Popper.js (ora Floating UI) esistevano specificamente perché era così difficile farlo bene.

L'anchor positioning CSS rende tutto dichiarativo:

css
.trigger {
  anchor-name: --my-trigger;
}
 
.tooltip {
  position: fixed;
  position-anchor: --my-trigger;
 
  /* Posiziona il centro-alto del tooltip al centro-basso del trigger */
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 0;
 
  /* Posizionamento fallback se deborda dal viewport */
  position-try-fallbacks: flip-block, flip-inline;
}

Gestione automatica delle collisioni con il viewport#

La proprietà position-try-fallbacks è la parte che avrebbe richiesto 200 righe di JavaScript. Dice al browser: "se il tooltip deborda dal viewport in basso, capovolgilo in alto. Se deborda a destra, capovolgilo a sinistra."

css
.dropdown-menu {
  position: fixed;
  position-anchor: --menu-button;
 
  /* Default: sotto il pulsante, allineato al bordo sinistro */
  top: anchor(bottom);
  left: anchor(left);
 
  /* Se non ci sta sotto, prova sopra. Se non ci sta allineato a sinistra, prova a destra */
  position-try-fallbacks: flip-block, flip-inline;
 
  /* Aggiungi un gap tra l'ancora e il dropdown */
  margin-top: 4px;
}

Fallback di posizione con nome#

Per un controllo maggiore sulle posizioni di fallback, puoi definire opzioni try personalizzate:

css
@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;
}

Il browser prova ogni fallback in ordine finché ne trova uno che mantiene l'elemento all'interno del viewport. Questo è il tipo di ragionamento spaziale che era genuinamente doloroso in JavaScript.

Anchoring con la Popover API#

L'anchor positioning si abbina perfettamente alla nuova Popover API:

html
<button popovertarget="my-popover" style="anchor-name: --btn">Impostazioni</button>
 
<div popover id="my-popover" style="
  position-anchor: --btn;
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 0;
  margin-top: 8px;
">
  Contenuto del popover qui
</div>

Nessun JavaScript per mostrare/nascondere (la Popover API gestisce questo). Nessun JavaScript per il posizionamento (l'anchor positioning gestisce questo). Nessun JavaScript per il comportamento di light-dismiss (anche la Popover API gestisce questo). L'intero pattern tooltip/popover/dropdown — un pattern che alimentava interi pacchetti npm — è ora HTML e CSS.

CSS Grid Subgrid: allineamento annidato che funziona davvero#

Grid è potente, ma aveva una limitazione frustrante: una griglia figlia non poteva allineare i suoi elementi alla griglia genitore. Se avevi una riga di card e volevi che titolo, contenuto e footer di ogni card si allineassero tra le card, eri senza speranza. La griglia interna di ogni card era indipendente.

Subgrid risolve questo.

Il problema dell'allineamento delle card#

css
/* Griglia genitore */
.card-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1.5rem;
}
 
/* Ogni card diventa una subgrid, allineando le righe al genitore */
.card {
  display: grid;
  grid-row: span 3; /* La card occupa 3 righe nel genitore */
  grid-template-rows: subgrid;
  gap: 0; /* La card controlla il proprio gap interno */
}
 
.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);
}

Ora i titoli di tutte e tre le card si allineano sulla stessa riga. I body si allineano. I footer si allineano. Anche quando una card ha un titolo su due righe e un'altra ne ha uno su una riga, l'allineamento è mantenuto dalla griglia genitore.

Prima di subgrid: gli hack#

Senza subgrid, ottenere questo allineamento richiedeva:

  1. Altezze fisse (fragile, si rompe con contenuto dinamico)
  2. Misurazione JavaScript (lenta, flash di disallineamento)
  3. Arrendersi e accettare il disallineamento (comune, brutto)
css
/* Il vecchio hack — fragile e si rompe con contenuto dinamico */
.card-title {
  min-height: 3rem; /* pregare che nessun titolo superi questo */
}
 
.card-body {
  min-height: 8rem; /* ancora più preghiere */
}

Subgrid rende l'hack non necessario. La griglia genitore distribuisce le altezze delle righe in base al contenuto più alto in ogni riga tra tutte le card.

Subgrid di colonne per i layout dei form#

Subgrid funziona anche sulle colonne, il che è perfetto per i layout dei form:

css
.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;
}

Tutte le label si allineano. Tutti gli input si allineano. La colonna delle label si auto-dimensiona alla label più larga. Nessuna larghezza hardcoded.

View Transitions API: navigazione tipo SPA senza framework#

Questa è la funzionalità più ambiziosa di questa lista. La View Transitions API ti permette di animare tra le navigazioni di pagina — incluse le navigazioni cross-document (click su link normali in un'app multi-pagina). Il tuo sito HTML statico può ora avere transizioni di pagina fluide e animate.

Transizioni cross-document#

Per abilitare le view transition cross-document, aggiungi una singola regola CSS sia alla vecchia che alla nuova pagina:

css
@view-transition {
  navigation: auto;
}

Questo è tutto. Il browser ora farà un cross-fade tra le pagine durante la navigazione. La transizione predefinita è una dissolvenza di opacità fluida. Nessun JavaScript. Nessun framework. Solo due righe di CSS su ogni pagina.

Personalizzare la transizione#

Puoi personalizzare le transizioni dando un nome a elementi specifici:

css
/* Su entrambe le pagine */
.page-header {
  view-transition-name: header;
}
 
.main-content {
  view-transition-name: content;
}
 
.hero-image {
  view-transition-name: hero;
}
 
/* Animazioni di transizione */
::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; /* Il nuovo hero appare semplicemente */
}
 
@keyframes slide-out {
  to {
    transform: translateX(-100%);
    opacity: 0;
  }
}
 
@keyframes slide-in {
  from {
    transform: translateX(100%);
    opacity: 0;
  }
}
 
@keyframes fade-out {
  to {
    opacity: 0;
  }
}

Quando navighi tra due pagine che hanno entrambe una .hero-image con view-transition-name: hero, il browser anima automaticamente l'immagine dalla sua posizione nella vecchia pagina alla sua posizione nella nuova pagina. È il pattern "shared element transition" dallo sviluppo mobile, ora nel browser.

View Transition per SPA#

Per le single-page app (React, Vue, Svelte, ecc.), l'API JavaScript è diretta:

css
/* Lato CSS — definisci le animazioni di transizione */
::view-transition-old(root) {
  animation: fade-out 0.2s ease-in;
}
 
::view-transition-new(root) {
  animation: fade-in 0.2s ease-out;
}
javascript
// Lato JS — avvolgi il tuo aggiornamento DOM in startViewTransition
document.startViewTransition(() => {
  // Aggiorna il DOM qui — React render, swap di innerHTML, ecc.
  updateContent(newPageData);
});

Il browser cattura uno snapshot del vecchio stato, esegue il tuo aggiornamento, cattura uno snapshot del nuovo stato e anima tra i due. Gli elementi con nome ottengono transizioni individuali; tutto il resto ottiene un crossfade predefinito.

Rispettare le preferenze dell'utente#

Rispetta sempre prefers-reduced-motion:

css
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

Cosa puoi smettere di usare#

Queste funzionalità eliminano collettivamente un sacco di tooling, librerie e pattern su cui ci siamo basati per anni. Ecco cosa ho rimosso o smesso di cercare:

Sass/SCSS per nesting e variabili#

Il CSS ha il nesting nativo. Il CSS ha le custom property (e le ha da anni). Le due ragioni principali per cui le persone ricorrevano a Sass sono ora nel linguaggio. Se stai ancora usando Sass solo per $variables e nesting, puoi smettere.

css
/* Prima: Sass */
$primary: #3b82f6;
 
.card {
  background: white;
  border: 1px solid lighten($primary, 40%);
 
  &:hover {
    border-color: $primary;
  }
 
  .title {
    color: darken($primary, 15%);
  }
}
 
/* Dopo: CSS nativo */
.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);
  }
}

La versione CSS è più potente — oklch fornisce manipolazione del colore percettivamente uniforme, color-mix funziona a runtime (il lighten di Sass è solo compile-time), e le custom property possono essere cambiate dinamicamente da JavaScript o media query.

JavaScript per le animazioni scroll#

Cancella il tuo codice IntersectionObserver per la comparsa allo scroll. Cancella il JavaScript della barra di progresso dello scroll. Cancella il tuo handler parallax scroll. animation-timeline: scroll() e animation-timeline: view() gestiscono tutto questo con prestazioni migliori (thread del compositor, non thread principale).

javascript
// Prima: JavaScript che puoi cancellare
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));
css
/* Dopo: CSS che sostituisce tutto quanto sopra */
.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 per il posizionamento#

Se stai usando Floating UI (ex Popper.js) solo per il posizionamento base di tooltip/popover, l'anchor positioning CSS lo sostituisce. Perdi alcune delle funzionalità avanzate (elementi virtuali, middleware personalizzato), ma per il caso d'uso del 90% — "metti questo popover vicino a quel pulsante e mantienilo nel viewport" — il CSS lo fa nativamente ora.

Hack di centratura con margin: auto#

Questo non è nuovo, ma vedo ancora margin: 0 auto ovunque. Gli strumenti di layout moderni lo rendono non necessario nella maggior parte dei casi:

css
/* Vecchio modo */
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 1rem;
}
 
/* Meglio: CSS Grid o Flexbox */
.page {
  display: grid;
  place-items: center;
}
 
/* Oppure: le container query rendono il container stesso responsive */
.content {
  container-type: inline-size;
  width: min(100% - 2rem, 1200px);
  margin-inline: auto; /* OK, ancora auto, ma margin-inline è più chiaro */
}

margin: 0 auto funziona ancora benissimo. Ma se ti ritrovi a usarlo per la centratura verticale o allineamenti complessi, usa flexbox o grid.

Media query per la responsività a livello di componente#

Questa è quella importante. Se stai scrivendo query @media per rendere un componente responsive, probabilmente stai sbagliando ora. Le container query dovrebbero essere il tuo default per la responsività a livello di componente. Riserva le query @media per decisioni a livello di pagina: cambi di layout, pattern di navigazione, stili di stampa.

css
/* Prima: media query per lo stile del componente (scope sbagliato) */
@media (min-width: 768px) {
  .product-card {
    flex-direction: row;
  }
}
 
/* Dopo: container query (scope giusto) */
.product-card-wrapper {
  container-type: inline-size;
}
 
@container (min-width: 400px) {
  .product-card {
    flex-direction: row;
  }
}

Il quadro generale#

Queste funzionalità non esistono isolatamente. Si compongono. Container query + :has() + nesting + oklch() + layer — usati insieme — ti danno un'esperienza di authoring CSS che sarebbe stata irriconoscibile cinque anni fa:

css
@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;
      }
    }
  }
}

Quel singolo blocco gestisce:

  • Layout responsive a livello di componente (container query)
  • Stilizzazione condizionale basata sul contenuto (:has())
  • Organizzazione pulita dei selettori (nesting)
  • Colori percettivamente uniformi (oklch)
  • Gestione prevedibile della specificità (@layer)

Nessun preprocessore. Nessun runtime CSS-in-JS. Nessuna classe utility per questo. Solo CSS, che fa ciò che il CSS avrebbe dovuto fare da sempre.

La piattaforma si è messa al passo. Finalmente. E ne è valsa la pena aspettare.

Articoli correlati