Gradienti, banding e perché il centro sembra spento
Pubblicato il 21/08/2025 · 17 min di lettura · Strumenti per sviluppatori
Daniel Okonkwo — Sviluppatore front-end e redattore Tech presso OneKitly
Performance web · Formati di file
Verificato su 6 fonti
Un gradiente CSS interpola i numeri memorizzati nei colori, e quei numeri sono codificati in gamma. sRGB spende i suoi 8 bit dove l'occhio è sensibile anziché in proporzione alla luce, quindi mediare due valori codificati non dà il colore con la quantità media di luce. Prendi rosso #ff0000 verso verde #00ff00. Il punto medio sRGB semplice è #808000, con luminanza relativa WCAG 0,1986. Linearizza entrambi gli estremi, medi lì e ricodifica: ottieni #bcbc00 con luminanza 0,4639 — esattamente la media delle luminanze degli estremi, come dev'essere una mezza luce. Il punto medio predefinito è il 57,2 % sotto, e quel deficit è la banda scura e spenta che si vede al centro di ogni gradiente a due colori. Un secondo problema, non collegato, è il banding. Otto bit per canale danno 256 valori, quindi una rampa fra due colori scuri vicini può contenere solo 28 colori distinti; stirata su 1920 pixel fa una banda larga in media 68,6 pixel, visibile dall'altro capo della stanza. Il dithering la nasconde scambiando rumore spaziale con risoluzione tonale. CSS consente ormai di correggere il primo problema direttamente con `in oklab` o `in oklch`, e la parola chiave di direzione della tinta decide da che parte della ruota viaggia il colore.
Interpolare in sRGB fa la media di numeri codificati in gamma: il punto medio da rosso a verde è #808000 quando la risposta a mezza luce è #bcbc00 — il 57,2 % di luce in meno. Il banding è un problema aritmetico distinto: 8 bit danno 256 gradini, e un gradiente scuro può averne solo 28. Ecco entrambi, calcolati.
La media è presa nelle unità sbagliate
Un linear-gradient in CSS fa la cosa ovvia: per ogni pixel calcola una posizione t fra 0 e 1 e restituisce i colori estremi miscelati secondo t. La finezza è che cosa miscela. Un codice esadecimale non è una misura di luce; è una parola di codice, e sRGB assegna quelle parole deliberatamente in modo non lineare per spendere i 256 valori disponibili dove la visione umana sa distinguerli. Il valore memorizzato è all'incirca la luce elevata a 1/2,2, non la luce stessa.
La funzione di trasferimento sRGB è scritta per esteso nella norma ed è facile da implementare. Per decodificare, dividi il valore a 8 bit per 255 per ottenere c, poi restituisci c ÷ 12,92 se c è al massimo 0,04045, e ((c + 0,055) ÷ 1,055) elevato a 2,4 altrimenti. Per codificare, inverti: moltiplica per 12,92 sotto 0,0031308, altrimenti 1,055 per il valore elevato a 1/2,4, meno 0,055. Queste due funzioni bastano per passare dalle parole di codice alla luce e ritorno.
Passa un solo numero e la portata del problema si vede già. Metà della luce — lineare 0,5 — si codifica in 0,735357, cioè 188 a 8 bit, ovvero #bcbcbc. Il colore che quasi tutti chiamano grigio al cinquanta per cento, #808080, si decodifica in 0,215861. Non è la metà di niente. È la parola di codice a metà del codice, cosa del tutto diversa dalla metà della luce.
Dal rosso al verde, misurato in entrambi i modi
Prendi #ff0000 verso #00ff00 e calcola il punto medio due volte. Il modo ingenuo media i valori memorizzati: (255 + 0) ÷ 2 = 127,5 per il rosso, idem per il verde, 0 per il blu. Fa #808000, e passandolo nel calcolo della luminanza WCAG dà 0,1986. Il modo corretto decodifica prima: il rosso diventa lineare (1, 0, 0) e il verde (0, 1, 0), la media è (0,5, 0,5, 0), e ricodificando escono 187,5 su entrambi i canali — #bcbc00, con luminanza 0,4639.
Il secondo numero è verificabile indipendentemente: la luminanza relativa di #ff0000 è 0,2126 e quella di #00ff00 è 0,7152, e la loro media è 0,4639 — esattamente ciò che misura il punto medio lineare. Questa è la definizione di metà in termini di luce, e il metodo lineare la riproduce fino all'ultima cifra. Il punto medio sRGB consegna invece 0,1986, il 57,2 % in meno. Manca ben più della metà della luce che dovrebbe stare al centro del gradiente.
Lo stesso deficit compare ovunque i due estremi siano lontani nello spazio dei codici. Nero verso bianco: punto medio sRGB #808080 a luminanza 0,2140, punto medio lineare #bcbcbc a 0,5000, l'identico deficit del 57,2 %. Bianco verso blu: #8080ff a 0,2708 contro #bcbcff a 0,5361, un deficit del 49,5 %. Non è una stranezza di colori particolari; è la forma della funzione di trasferimento, e vale per ogni gradiente predefinito sul web.
Dal blu al giallo si passa per il grigio — difetto diverso, rimedio diverso
Viene voglia di addossare anche al problema della gamma il centro grigio di un gradiente blu-giallo, e si scopre che è falso. Calcola entrambi i punti medi per #0000ff verso #ffff00. Il punto medio sRGB è #808080 con luminanza 0,2140. Il punto medio in luce lineare è #bcbcbc con luminanza 0,5000. La versione lineare è molto più chiara, esattamente come prevede la sezione precedente — e resta un grigio perfettamente neutro. Misurati in OKLCH, entrambi hanno croma 0,000. Lavorare in luce lineare ha risolto il buio e non ha fatto assolutamente nulla contro il grigiore.
Il motivo è che blu e giallo sono quasi opposti nel cubo RGB, quindi mediare i loro canali annulla il colore qualunque sia l'unità. Ogni interpolazione rettangolare — sRGB, sRGB lineare, perfino OKLab, che è rettangolare pur essendo percettivo — percorre una retta che passa per il centro dello spazio colore, e il centro dello spazio colore è neutro. Solo un'interpolazione polare, che trasporta il croma e ruota la tinta anziché mediare canali opposti, gira all'esterno.
Una dimostrazione più pulita usa una coppia che resta dentro il gamut sRGB: #007bff verso #ff6b00, un blu e un arancione le cui tinte OKLCH sono 257,2° e 45,1°. In sRGB semplice il punto medio è #807380, un grigio-malva spento con croma OKLCH 0,024 — il colore è di fatto morto. In sRGB lineare è #bc73bc, croma 0,131. In OKLab è #a285a5, croma 0,058. In OKLCH per l'arco di tinta breve è #d454c9, croma 0,208: un magenta vivo, e il croma è ora all'incirca la media di quelli degli estremi anziché una frazione. Gli stessi due stop, cinque centri diversi.
Banding: 256 valori stirati su 2560 pixel
Il banding non c'entra nulla con lo spazio di interpolazione. È un problema di conteggio. Un canale a 8 bit ha 256 valori possibili, quindi un gradiente non può mai contenere più colori distinti di quanti ne coprano i suoi estremi. Se una rampa è disegnata su più pixel di quanti valori distinti abbia, alcuni valori devono ripetersi, e ogni tratto ripetuto è una striscia piatta.
Rasterizzare le rampe pixel per pixel e contare dà le cifre reali. Un gradiente completo dal nero al bianco contiene 256 colori distinti per quanto largo lo si disegni. Su 320 pixel la banda media è 1,3 pixel e il tratto piatto più lungo 2: nessuno li vedrà. Su 1920 pixel la media sale a 7,5 pixel e il tratto più lungo a 8. Su 2560 pixel è 10,0 e 11 — una striscia visibile, sulla massima escursione tonale possibile.
I gradienti reali sono ben peggiori, perché i designer coprono di rado l'intera escursione. Un hero scuro alla moda che va da #1a1a2e a #16213e contiene appena 28 colori distinti. Su 1920 pixel fa una banda media di 68,6 pixel, con il tratto piatto più lungo che arriva a 120 pixel. Su 2560 pixel la media è 91,4 pixel. Una superficie discreta bianco su bianco, da #ffffff a #f5f5f5, contiene 11 colori: a 1920 pixel la banda media misura 174,5 pixel. Non sono artefatti che un visitatore potrebbe notare; sono strisce più larghe di un pulsante.
Da qui esce una regola utilizzabile. Se prendi 3 pixel come la larghezza oltre la quale una banda piatta si legge come uno spigolo anziché come tono continuo, il banding diventa visibile appena la larghezza disegnata supera tre volte il numero di livelli distinti. Una rampa completa a 256 livelli regge fino a 768 pixel. Una a 52 livelli regge fino a 156. L'hero a 28 livelli regge fino a 84 pixel, e la superficie a 11 livelli fino a 33. Quasi ogni gradiente decorativo di una pagina moderna è disegnato ben oltre il proprio limite.
Il dithering, e che cosa darebbero più bit
Il dithering è il rimedio standard e funziona scambiando un tipo di errore con un altro. Invece di arrotondare ogni pixel al valore disponibile più vicino, si aggiunge un po' di rumore prima dell'arrotondamento, così i pixel vicini a un confine si distribuiscono fra i due valori vicini in proporzione alla loro vicinanza. La media su qualsiasi piccola area risulta allora corretta anche se nessun pixel lo è, e l'occhio integra l'area anziché il pixel. La striscia visibile lascia il posto a una grana leggera.
Sul web raramente controlli il rasterizzatore, quindi il dithering si applica di solito a mano: sovrapponi al gradiente una texture di rumore a mosaico e a contrasto bassissimo, con un'opacità dell'uno o due per cento, oppure genera il gradiente come SVG con un livello feTurbulence. Entrambi gli approcci spezzano i tratti piatti. Il trucco è tenere il rumore sotto la soglia in cui si legge come texture: se vedi la grana, hai esagerato e hai scambiato una striscia con una superficie sporca.
Più bit eliminerebbero del tutto la necessità. A 10 bit per canale ci sono 1024 livelli, e la stessa rampa a escursione piena su 1920 pixel dà una banda media di 1,88 pixel — sotto la soglia di visibilità, quindi senza dithering. I display a gamut esteso e le catene HDR offrono sempre più quella profondità, e i colori CSS scritti con oklch() o con la funzione color() sanno esprimerla. Il vincolo è l'intera catena: un valore a 10 bit composto in un buffer a 8 bit torna a essere un valore a 8 bit.
Le parole chiave CSS: in oklch, in lab e la direzione della tinta
CSS Color 4 consente di nominare lo spazio di interpolazione direttamente dentro la funzione di gradiente: linear-gradient(in oklch, #007bff, #ff6b00). Gli spazi accettati includono srgb, srgb-linear, lab, oklab, lch, oklch, hsl, hwb e xyz. Quelli rettangolari — srgb, srgb-linear, lab, oklab, xyz — interpolano le coordinate componente per componente. Quelli polari — hsl, hwb, lch, oklch — interpolano un angolo di tinta, ed è lì che vivono le sorprese.
Per uno spazio polare puoi aggiungere un metodo di interpolazione della tinta: shorter, longer, increasing o decreasing, ciascuno seguito dalla parola hue. Shorter prende l'arco di al massimo 180°, che è il valore predefinito. Longer prende l'arco complementare, facendo il giro lungo. Increasing obbliga l'angolo a crescere, decreasing a calare. Esempio svolto: interpolando dalla tinta 20 alla tinta 320, shorter dà un arco di −60° e il percorso 20, 5, 350, 335, 320, mentre longer dà un arco di +300° e il percorso 20, 95, 170, 245, 320 — attraverso il verde e il blu, un gradiente del tutto diverso dagli stessi due stop.
C'è una seconda cosa da sorvegliare negli spazi polari. OKLCH può descrivere colori fuori dal gamut sRGB, e il percorso polare si gonfia verso l'esterno: il punto medio OKLCH del blu #0000ff e del giallo #ffff00 si calcola in coordinate che mappano su una terna RGB fuori intervallo e deve subire un gamut mapping prima di essere mostrato. I browser ci pensano loro, ma il risultato porterà meno croma di quanto l'aritmetica avesse chiesto, quindi un gradiente brillante in un selettore di colore può arrivare parecchio più smorzato sullo schermo. La coppia dentro il gamut usata sopra evita del tutto il problema, ed è questa la lezione pratica: controlla il centro, non solo gli estremi.
La dissolvenza al trasparente e l'alfa premoltiplicato
L'ultimo bug classico dei gradienti è la foschia grigia alla fine di una dissolvenza al trasparente. Accade quando l'interpolazione tratta colore e alfa come quattro numeri indipendenti. Dissolvi il bianco verso `transparent`: poiché la parola chiave CSS `transparent` vale rgb(0 0 0 / 0) — nero trasparente — il canale di colore viene trascinato dal bianco verso il nero mentre l'alfa cala. A metà il colore è grigio al 50 % e l'alfa vale 0,5, quindi su una pagina bianca il pixel viene reso come #bfbfbf: una banda sporca visibile dove dovrebbe esserci una dissolvenza pulita.
L'alfa premoltiplicato elimina l'artefatto. Prima di interpolare, moltiplica ogni canale di colore per il proprio alfa; interpola insieme i canali premoltiplicati e l'alfa; poi ridividi alla fine. Uno stop completamente trasparente non contribuisce in nulla al colore, perché i suoi canali premoltiplicati sono tutti zero, quindi non può trascinare il colore visibile da nessuna parte. Eseguire la stessa dissolvenza da bianco a nero trasparente in premoltiplicato dà #ffffff al punto medio su fondo bianco invece di #bfbfbf, e #808080 su nero invece di #404040.
I gradienti CSS sono specificati per interpolare in spazio premoltiplicato, quindi i browser moderni fanno la cosa giusta. La correzione manuale, che vale la pena conoscere perché sopravvive nel codice canvas, in SVG, negli editor di immagini e nei motori più vecchi, consiste nel nominare esplicitamente lo stop trasparente nel colore da cui parti: usa rgb(255 255 255 / 0) anziché la parola chiave `transparent`. Calcola entrambi i punti medi e l'equivalenza è esatta — dissolvere il bianco verso un bianco trasparente dà #ffffff su bianco e #808080 su nero con entrambi i metodi, premoltiplicato o no.
| Spazio di interpolazione | CSS | Punto medio | Croma OKLCH | Risultato |
|---|---|---|---|---|
| sRGB (predefinito) | in srgb | #807380 | 0,024 | Scuro e quasi incolore |
| sRGB in luce lineare | in srgb-linear | #bc73bc | 0,131 | Luminosità corretta, tinta smorzata |
| OKLab (rettangolare) | in oklab | #a285a5 | 0,058 | Chiarezza uniforme, ancora slavato |
| OKLCH, arco breve | in oklch shorter hue | #d454c9 | 0,208 | Magenta vivo, croma preservato |
| OKLCH, arco lungo | in oklch longer hue | #00b145 | 0,208 | Verde, fuori gamut e ritagliato |
Domande frequenti
- Perché il mio gradiente a due colori ha una banda scura al centro?
- Perché il browser ha mediato numeri codificati in gamma anziché luce. sRGB memorizza un valore vicino alla luce elevata a 1/2,2, quindi la media aritmetica di due valori memorizzati corrisponde a molta meno luce della media delle due luci. Rosso #ff0000 verso verde #00ff00 lo concretizza: il punto medio sRGB semplice è #808000 con luminanza relativa 0,1986, mentre il vero punto medio a mezza luce è #bcbc00 a 0,4639 — esattamente la media delle luminanze degli estremi, 0,2126 e 0,7152. Il comportamento predefinito resta il 57,2 % sotto la luce che dovrebbe portare, e quel deficit è la banda scura. La correzione in una riga in CSS è nominare uno spazio di interpolazione migliore: linear-gradient(in oklab, red, lime) o linear-gradient(in srgb-linear, red, lime). Entrambi alzano il centro. Se vuoi anche che il centro resti colorato invece di farsi neutro, ti serve uno spazio polare — in oklch — perché quello è un problema separato, descritto nella risposta successiva.
- Perché dal blu al giallo si passa per il grigio anche in uno spazio migliore?
- Perché il grigiore e il buio sono difetti diversi. Blu e giallo sono quasi opposti, quindi mediare i loro canali annulla il colore in qualsiasi unità. I numeri lo confermano: il punto medio sRGB di #0000ff e #ffff00 è #808080 con luminanza 0,2140, e il punto medio in luce lineare è #bcbcbc con luminanza 0,5000. La versione lineare è molto più chiara, come previsto, ed entrambe hanno croma OKLCH 0,000 — perfettamente neutro. Perfino OKLab, benché percettivo, dà #6cabc7 con un croma di soli 0,077, perché OKLab è rettangolare e una retta fra due colori opposti passa per l'asse neutro. Solo un'interpolazione polare lo evita, perché ruota la tinta e trasporta il croma anziché mediare canali opposti. Usa linear-gradient(in oklch, blue, yellow). Tieni presente che per questa coppia specifica il punto medio polare cade fuori dal gamut sRGB e subirà gamut mapping, quindi otterrai meno croma del promesso. Se vuoi un centro vivace con certezza, aggiungi uno stop intermedio esplicito nella tinta che vuoi davvero.
- Quanto può essere largo un gradiente prima di iniziare a bandeggiare?
- Dipende interamente da quanto sono distanti gli estremi, non dalla dimensione dell'elemento. Conta i colori a 8 bit distinti che la rampa può contenere, poi moltiplica per circa tre: prendendo 3 pixel come la larghezza oltre la quale una banda piatta si legge come uno spigolo, quel prodotto è la larghezza in cui compare il banding. Una rampa completa dal nero al bianco contiene 256 colori e regge fino a circa 768 pixel. Una rampa da #0f172a a un ardesia vicino contiene 52 colori e regge fino a 156. Un hero scuro alla moda da #1a1a2e a #16213e contiene solo 28 colori e bandeggia oltre gli 84 pixel — cioè bandeggia in ogni layout in cui comparirà. Una superficie quasi bianca da #ffffff a #f5f5f5 contiene 11 colori e cede a 33 pixel; disegnata su 1920, la sua banda media misura 174,5 pixel. Tre mosse pratiche: allargare la distanza tonale fra gli stop per avere più livelli, aggiungere un rumore tenue all'uno o due per cento di opacità per fare dithering sui confini, oppure orientare il gradiente in diagonale anziché in linea retta, il che allunga la rampa rispetto all'elemento e disallinea i bordi delle bande dal layout.
- Che cosa fa davvero `longer hue`, e quando lo vorrei?
- Costringe la tinta a fare il giro lungo del cerchio invece di prendere l'arco corto. In un'interpolazione polare il browser ha due percorsi candidati fra due angoli di tinta e per impostazione predefinita sceglie quello di al massimo 180°. Interpolando dalla tinta 20 alla tinta 320, `shorter hue` dà un arco di −60° e passa per 5, 350 e 335 — un breve tragitto nei rossi. `longer hue` dà un arco di +300° e passa per 95, 170 e 245 — verdi, ciani e blu, di fatto l'intero spettro. Gli stessi due stop, gradiente del tutto diverso. Vale la pena ricorrere alla parola chiave quando vuoi una sfumatura arcobaleno da due estremi senza scrivere stop intermedi, cosa comune per indicatori di avanzamento, rampe di visualizzazione dati e bordi decorativi. Ci sono altre due parole chiave: `increasing hue` obbliga l'angolo a crescere e `decreasing hue` a calare, ed è così che controlli la direzione e non solo la lunghezza. Attenzione al caso limite in cui le due tinte distano esattamente 180°: shorter e longer descrivono allora la stessa lunghezza d'arco e devi usare increasing o decreasing per dire da che parte.
- Perché la mia dissolvenza al trasparente sembra sporca sul bordo?
- Quasi sempre perché lo stop trasparente è nero trasparente. La parola chiave CSS `transparent` si espande in rgb(0 0 0 / 0), quindi un gradiente scritto da bianco `to transparent` trascina il canale di colore dal bianco verso il nero mentre l'alfa cala. Se colore e alfa sono interpolati separatamente, il punto medio è grigio al 50 % con alfa 0,5, che composto su una pagina bianca dà #bfbfbf — una foschia grigia visibile. Interpolare in spazio premoltiplicato risolve: moltiplica ogni colore per il suo alfa prima di miscelare, misceli, poi ridividi; uno stop completamente trasparente non contribuisce più al colore perché i suoi canali premoltiplicati sono zero. Lo stesso punto medio si compone allora come #ffffff su bianco e #808080 su nero. I gradienti CSS sono specificati in premoltiplicato, quindi i browser attuali qui sono corretti; il bug sopravvive nel codice canvas, in SVG, negli editor di immagini e nei motori più vecchi. La correzione portabile è scrivere lo stop nel colore da cui parti — rgb(255 255 255 / 0) anziché `transparent` — che dà risultati identici con entrambi i metodi e non costa nulla.
Articoli che potrebbero interessarti
Tutte le guide →Strumenti correlati
Fonti
- W3C — CSS Color Module Level 4 — interpolation, colour spaces, hue interpolation methods and premultiplied alpha
- W3C — CSS Images Module Level 4 — gradient interpolation and colour stop resolution
- IEC — IEC 61966-2-1:1999 — Default RGB colour space (sRGB), the piecewise transfer function
- Charles Poynton — Gamma FAQ — why encoding is nonlinear and what breaks when you average encoded values
- Björn Ottosson — A perceptual color space for image processing (OKLab), 2020
- W3C — Web Content Accessibility Guidelines (WCAG) 2.2 — the relative luminance definition used for the midpoint measurements
Hai notato un errore in questo articolo?