Skeleton Loading con CSS: Migliora la Perceived Performance

Skeleton Loading con CSS: Migliora la Perceived Performance

Perché lo Skeleton Loading nel 2026

Nel 2026 l’utente medio abbandona una pagina se non vede contenuto entro 2-3 secondi. Non si tratta solo di velocità reale — si tratta di perceived performance, cioè di quanto veloce sembra il tuo sito all’utente. Lo skeleton loading è una delle tecniche più efficaci per migliorare questa percezione: invece di mostrare uno spinner generico o una pagina vuota, mostri una struttura visiva che anticipa il layout del contenuto finale.

Questo approccio è utilizzato da Facebook, LinkedIn, YouTube e praticamente qualsiasi prodotto digital moderno. Il principio è semplice: il cervello umano percepisce il progresso strutturato come più rassicurante del nulla. Un layout grigio con animazioni shimmer comunica “sto caricando, fidati” mentre uno spinner dice “aspetta” senza dare contesto.

Dal punto di vista dei Core Web Vitals, lo skeleton loading incide direttamente su LCP e CLS. Se stai già lavorando sull’ottimizzazione delle Web Performance, aggiungere skeleton screens è il passo successivo naturale. In questo articolo vedremo come implementarlo con CSS puro, senza dipendenze esterne, con esempi pronti per la produzione.

Cos’è il Perceived Performance e Perché Conta

La perceived performance è la velocità percepita dall’utente, che spesso diverge dalla velocità reale misurata con strumenti come Lighthouse. Un sito che carica in 3 secondi ma mostra subito uno skeleton può sembrare più veloce di uno che carica in 2 secondi ma mostra una pagina bianca fino alla fine.

I ricercatori UX hanno dimostrato che gli utenti sottostimano i tempi di attesa quando vedono feedback visivo progressivo. Questo si ricollega ai principi di Jakob Nielsen: il sistema deve sempre tenere l’utente informato su cosa sta succedendo. Lo skeleton loading è l’implementazione moderna di quel principio.

Tre metriche che lo skeleton loading impatta direttamente:

  • CLS (Cumulative Layout Shift): il skeleton occupa lo spazio che il contenuto occuperà, eliminando i salti di layout.
  • LCP (Largest Contentful Paint): con skeleton correttamente dimensionato, il browser non deve ricalcolare il layout quando arriva il contenuto reale.
  • Bounce rate: gli utenti restano più a lungo se vedono che qualcosa sta accadendo.

Se stai ottimizzando anche altri aspetti delle performance, leggi la guida su come ottimizzare LCP per siti più veloci: skeleton e LCP sono strettamente collegati.

La Base: Skeleton Loading con CSS Puro

Partiamo dall’implementazione più essenziale. Un skeleton screen è composto da elementi HTML con una classe .skeleton che simulano il layout del contenuto. L’animazione shimmer è ottenuta con un gradiente CSS animato.

/* Base skeleton: un elemento grigio con animazione shimmer */
.skeleton {
  background-color: #e2e8f0;
  border-radius: 4px;
  position: relative;
  overflow: hidden;
}

/* L'animazione shimmer: il cuore dello skeleton loading */
.skeleton::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    90deg,
    transparent 0%,
    rgba(255, 255, 255, 0.4) 50%,
    transparent 100%
  );
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.5s ease-in-out infinite;
}

@keyframes skeleton-shimmer {
  0%   { background-position: -200% 0; }
  100% { background-position:  200% 0; }
}

/* Varianti dimensionali */
.skeleton-text {
  height: 1em;
  margin-bottom: 0.5em;
  width: 100%;
}

.skeleton-text.short  { width: 60%; }
.skeleton-text.medium { width: 80%; }

.skeleton-avatar {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  flex-shrink: 0;
}

.skeleton-image {
  width: 100%;
  aspect-ratio: 16/9;
}

Il trucco chiave è l’utilizzo di ::after con position: absolute; inset: 0 per sovrapporre il gradiente animato all’elemento. overflow: hidden sull’elemento padre fa sì che l’animazione non fuoriesca dai bordi.

Skeleton Screen per Card UI: Esempio Completo

Vediamo un esempio pratico: una card prodotto con avatar, titolo, descrizione e immagine. Questo pattern è identico a quello usato nei feed social e negli e-commerce.

<!-- Skeleton card (visibile durante il caricamento) -->
<div class="card skeleton-card">
  <div class="card-image skeleton skeleton-image"></div>
  <div class="card-body">
    <div class="card-header">
      <div class="skeleton skeleton-avatar"></div>
      <div class="card-meta">
        <div class="skeleton skeleton-text medium"></div>
        <div class="skeleton skeleton-text short"></div>
      </div>
    </div>
    <div class="skeleton skeleton-text"></div>
    <div class="skeleton skeleton-text"></div>
    <div class="skeleton skeleton-text short"></div>
  </div>
</div>

<!-- Card reale (nascosta, mostrata dopo il caricamento) -->
<div class="card real-card" hidden>
  <img src="prodotto.webp" alt="Prodotto" />
  <div class="card-body">
    <div class="card-header">
      <img src="avatar.webp" alt="Autore" class="avatar" />
      <div class="card-meta">
        <p class="author">Mario Rossi</p>
        <p class="date">10 luglio 2026</p>
      </div>
    </div>
    <p>Descrizione del prodotto che spiega le feature principali...</p>
  </div>
</div>
/* Layout della card */
.card {
  border-radius: 12px;
  overflow: hidden;
  background: #fff;
  box-shadow: 0 1px 3px rgba(0,0,0,.1);
  max-width: 380px;
}

.card-body {
  padding: 16px;
}

.card-header {
  display: flex;
  gap: 12px;
  align-items: center;
  margin-bottom: 16px;
}

.card-meta {
  flex: 1;
  min-width: 0;
}

/* Tema scuro: adatta i colori */
@media (prefers-color-scheme: dark) {
  .skeleton {
    background-color: #2d3748;
  }
  .skeleton::after {
    background: linear-gradient(
      90deg,
      transparent 0%,
      rgba(255, 255, 255, 0.08) 50%,
      transparent 100%
    );
    background-size: 200% 100%;
    animation: skeleton-shimmer 1.5s ease-in-out infinite;
  }
}

JavaScript: Gestire la Transizione Skeleton → Contenuto Reale

Lo skeleton loading senza logica di transizione non serve a nulla. Dobbiamo mostrare il skeleton durante il fetch e sostituirlo con i dati reali quando arrivano. Ecco il pattern corretto:

// skeleton-loader.js — pattern production-ready

class SkeletonLoader {
  constructor(skeletonEl, realEl) {
    this.skeleton = skeletonEl;
    this.real     = realEl;
  }

  // Mostra skeleton, nasconde contenuto reale
  show() {
    this.skeleton.removeAttribute("hidden");
    this.real.setAttribute("hidden", "");
  }

  // Nasconde skeleton, mostra contenuto reale con fade
  hide() {
    // Aggiunge transizione opacity prima di rimuovere hidden
    this.real.style.opacity = "0";
    this.real.removeAttribute("hidden");

    requestAnimationFrame(() => {
      this.real.style.transition = "opacity 0.3s ease";
      this.real.style.opacity    = "1";
    });

    this.skeleton.setAttribute("hidden", "");
  }
}

// Utilizzo con fetch
async function loadProductCard(productId) {
  const loader = new SkeletonLoader(
    document.querySelector(".skeleton-card"),
    document.querySelector(".real-card")
  );

  loader.show();

  try {
    const res  = await fetch(`/api/products/${productId}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = await res.json();

    // Popola il contenuto reale
    populateCard(data);
    loader.hide();
  } catch (err) {
    console.error("Errore caricamento prodotto:", err);
    // Mostra uno stato di errore invece del skeleton
    showErrorState(err);
    loader.hide();
  }
}

function populateCard(data) {
  document.querySelector(".real-card img").src = data.image;
  document.querySelector(".author").textContent = data.author;
  document.querySelector(".date").textContent   = formatDate(data.date);
}

function formatDate(iso) {
  return new Intl.DateTimeFormat("it-IT", {
    day: "numeric", month: "long", year: "numeric"
  }).format(new Date(iso));
}

Il requestAnimationFrame nella funzione hide() è fondamentale: senza di esso il browser potrebbe non applicare la transizione perché le due modifiche CSS avvengono nello stesso frame di rendering. Questo è lo stesso pattern che trovi nei framework moderni per evitare il layout thrashing.

Se stai usando React, il concetto è lo stesso ma con Suspense e loading states. Se vuoi approfondire la gestione degli stati asincroni in TypeScript, la guida su Zod per la validazione TypeScript è complementare: puoi usare Zod per validare la risposta API prima di popolare il DOM.

Skeleton Loading con CSS Custom Properties: Tema Unificato

Invece di ripetere i colori e i timing ovunque, usa le CSS Custom Properties per creare un sistema di skeleton coerente e facile da aggiornare:

/* Definizione variabili skeleton — metti nel :root o nel tuo design token file */
:root {
  --skeleton-base:       #e2e8f0;
  --skeleton-highlight:  rgba(255, 255, 255, 0.5);
  --skeleton-duration:   1.5s;
  --skeleton-timing:     ease-in-out;
  --skeleton-radius:     4px;
}

/* Dark mode automatica */
@media (prefers-color-scheme: dark) {
  :root {
    --skeleton-base:      #2d3748;
    --skeleton-highlight: rgba(255, 255, 255, 0.07);
  }
}

/* Override per temi colorati (es. brand color skeleton) */
.skeleton-brand {
  --skeleton-base:      #dbeafe;  /* blue-100 */
  --skeleton-highlight: rgba(59, 130, 246, 0.15);
}

/* Il componente skeleton usa solo le variabili */
.skeleton {
  background-color: var(--skeleton-base);
  border-radius: var(--skeleton-radius);
  position: relative;
  overflow: hidden;
}

.skeleton::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    90deg,
    transparent 0%,
    var(--skeleton-highlight) 50%,
    transparent 100%
  );
  background-size: 200% 100%;
  animation: skeleton-shimmer var(--skeleton-duration) var(--skeleton-timing) infinite;
}

/* Reduce motion: rispetta le preferenze di accessibilità */
@media (prefers-reduced-motion: reduce) {
  .skeleton::after {
    animation: none;
    opacity: 0.5;
  }
}

@keyframes skeleton-shimmer {
  from { background-position: -200% 0; }
  to   { background-position:  200% 0; }
}

La media query prefers-reduced-motion è obbligatoria per l’accessibilità: alcuni utenti con disturbi vestibolari o epilessia sono disturbati dalle animazioni continue. Disabilitar le disabilitiamo o le rendiamo statiche in quel caso.

Se stai costruendo un design system completo, l’utilizzo di variabili CSS per i skeleton si integra perfettamente con quello che trovi nella guida su CSS Avanzato: Layout, Animazioni e Best Practice. I token di design per skeleton, colori e spacing seguono tutti lo stesso sistema.

Pattern Avanzati: Lista, Tabella e Feed Infinito

Per liste di elementi o feed infiniti, generare skeleton dinamicamente in JavaScript è più scalabile che hardcodarli nell’HTML:

// Genera N skeleton cards dinamicamente
function createSkeletonList(container, count = 5) {
  const template = `
    <div class="list-item skeleton-item">
      <div class="skeleton skeleton-avatar"></div>
      <div class="list-item-content">
        <div class="skeleton skeleton-text medium"></div>
        <div class="skeleton skeleton-text short"></div>
      </div>
      <div class="skeleton skeleton-badge"></div>
    </div>
  `;

  // Usa DocumentFragment per una sola operazione DOM
  const fragment = document.createDocumentFragment();
  for (let i = 0; i < count; i++) {
    const div = document.createElement("div");
    div.innerHTML = template.trim();
    fragment.appendChild(div.firstChild);
  }

  container.appendChild(fragment);
  return container;
}

// Feed infinito con IntersectionObserver
function setupInfiniteSkeletonFeed(feedEl) {
  let page = 1;
  let loading = false;

  const sentinel = document.createElement("div");
  feedEl.appendChild(sentinel);

  const observer = new IntersectionObserver(async (entries) => {
    if (!entries[0].isIntersecting || loading) return;
    loading = true;

    // Mostra skeleton per i prossimi item
    const skeletonContainer = document.createElement("div");
    createSkeletonList(skeletonContainer, 3);
    feedEl.insertBefore(skeletonContainer, sentinel);

    // Carica i dati reali
    try {
      const items = await fetchPage(page++);
      skeletonContainer.remove();
      renderItems(feedEl, items, sentinel);
    } catch (e) {
      skeletonContainer.remove();
      renderError(feedEl, sentinel);
    } finally {
      loading = false;
    }
  }, { rootMargin: "200px" });

  observer.observe(sentinel);
  return observer;
}

// Render items reali nell'ordine corretto
function renderItems(container, items, sentinel) {
  const fragment = document.createDocumentFragment();
  items.forEach(item => {
    const el = buildItemElement(item);
    fragment.appendChild(el);
  });
  container.insertBefore(fragment, sentinel);
}

L’IntersectionObserver è la soluzione moderna per il lazy loading e il feed infinito: zero polling, zero scroll listeners, zero memory leak. Il rootMargin: "200px" fa sì che il fetch parta prima che l’utente raggiunga il fondo, rendendo lo scroll fluido.

Questo pattern si integra naturalmente con le tecniche di Intersection Observer per animazioni e lazy load che probabilmente stai già usando.

Skeleton Loading in React e Next.js

In React, il pattern skeleton si integra con Suspense e i loading states dei componenti. Con Next.js 15+, i file loading.tsx automatizzano questo processo a livello di route.

// components/SkeletonCard.tsx
import type { FC } from "react";

interface SkeletonCardProps {
  count?: number;
}

const SkeletonCard: FC<SkeletonCardProps> = ({ count = 1 }) => {
  return (
    <>
      {Array.from({ length: count }).map((_, i) => (
        <div key={i} className="card" aria-hidden="true" aria-label="Caricamento in corso">
          <div className="skeleton skeleton-image" style={{ aspectRatio: "16/9" }} />
          <div className="card-body">
            <div className="card-header">
              <div className="skeleton skeleton-avatar" />
              <div className="card-meta" style={{ flex: 1 }}>
                <div className="skeleton skeleton-text medium" />
                <div className="skeleton skeleton-text short" />
              </div>
            </div>
            <div className="skeleton skeleton-text" />
            <div className="skeleton skeleton-text" />
            <div className="skeleton skeleton-text short" />
          </div>
        </div>
      ))}
    </>
  );
};

export default SkeletonCard;

// app/products/loading.tsx (Next.js App Router)
import SkeletonCard from "@/components/SkeletonCard";

export default function ProductsLoading() {
  return (
    <section aria-label="Caricamento prodotti">
      <div className="product-grid">
        <SkeletonCard count={6} />
      </div>
    </section>
  );
}

// app/products/page.tsx
import { Suspense } from "react";
import ProductList from "@/components/ProductList";
import SkeletonCard from "@/components/SkeletonCard";

export default function ProductsPage() {
  return (
    <Suspense fallback={
      <div className="product-grid">
        <SkeletonCard count={6} />
      </div>
    }>
      <ProductList />
    </Suspense>
  );
}

L’attributo aria-hidden="true" e aria-label sono fondamentali: i screen reader non devono leggere gli skeleton (che non hanno contenuto significativo), ma devono sapere che il caricamento è in corso. È buona pratica aggiungere anche un role="status" su un elemento visivamente nascosto che annuncia il caricamento agli assistive technology.

Se vuoi approfondire Next.js e le sue feature di performance, la guida su Next.js 16: Turbopack, App Router Avanzato e le Novità del 2026 copre i loading states in modo approfondito nel contesto del nuovo App Router.

FAQ e Domande Frequenti

Skeleton loading vs spinner: quale scegliere?

La regola pratica è semplice: usa lo skeleton loading quando conosci la struttura del contenuto che stai caricando (card, liste, articoli) e usi lo spinner per operazioni brevi (<1 secondo) o quando la struttura del risultato è imprevedibile. Lo skeleton riduce il perceived time di attesa del 20-30% rispetto allo spinner secondo gli studi UX di Google, perché il cervello lo interpreta come progresso, non come attesa.

Come gestire il CLS (Cumulative Layout Shift) con lo skeleton?

Il CLS si verifica quando il contenuto reale ha dimensioni diverse dallo skeleton. La soluzione: usa aspect-ratio invece di altezze fisse dove possibile, e definisci min-height basata sul contenuto atteso. Per le card con testo variabile, usa un numero fisso di skeleton text lines e accetta una piccola variazione. Evita di cambiare il numero di elementi tra skeleton e reale: se mostri 3 skeleton card, mostra 3 card reali (non 2 o 4).

Lo skeleton loading funziona con il Server-Side Rendering?

Sì, anzi è la combinazione ideale. Con SSR/SSG il contenuto above the fold è già nell’HTML iniziale, quindi non hai bisogno di skeleton per quello. Usi lo skeleton solo per il contenuto caricato dinamicamente lato client dopo l’idratazione: dati personalizzati, feed infiniti, sezioni che dipendono dall’utente loggato. In Next.js App Router, i Server Components eliminano il bisogno di skeleton per il contenuto statico, mentre i Client Components con dati dinamici usano ancora il pattern.

Quanto deve durare l’animazione shimmer?

Il valore ottimale è tra 1.2s e 1.8s. Sotto 1 secondo l’animazione è troppo veloce e risulta nervosa. Sopra 2 secondi sembra lenta. 1.5s è il sweet spot standard usato da Material Design e dai design system di Facebook e LinkedIn. Usa sempre ease-in-out come timing function: crea un effetto di “onda” più naturale rispetto a linear. Ricorda sempre di aggiungere @media (prefers-reduced-motion: reduce) per disabilitare o rallentare l’animazione per gli utenti sensibili al movimento.

Conclusione

Lo skeleton loading è una di quelle tecniche che ha un impatto enorme sull’UX con uno sforzo implementativo relativamente basso. Bastano 30-40 righe di CSS puro per avere un sistema skeleton completo, riutilizzabile e accessibile.

I punti chiave da portare via: usa sempre prefers-reduced-motion, mantieni le dimensioni dello skeleton fedeli al contenuto reale per minimizzare il CLS, gestisci la transizione skeleton—contenuto con un fade e requestAnimationFrame, e usa le CSS Custom Properties per un sistema di design consistente.

Con questi pattern sei pronto per implementare skeleton loading in qualsiasi progetto: vanilla JS, React, Next.js, o qualsiasi altro framework. La perceived performance migliora immediatamente, il bounce rate scende e i tuoi Core Web Vitals ringraziano.

Suggerimenti e Risorse

🔧 Strumento: Usa Chrome DevTools — tab Network — per simulare connessioni lente (3G Fast/Slow) e verificare che lo skeleton appaia prima del contenuto reale. Imposta il throttling su “Slow 3G” e controlla il waterfall: il tuo skeleton dovrebbe essere visibile entro il primo secondo.

💡 Pro tip: Per i progetto React, la libreria react-loading-skeleton (zero dipendenze oltre React) offre un’API dichiarativa molto pulita. Ma prima di aggiungerla, valuta se le 30 righe di CSS custom che hai visto in questo articolo bastano per il tuo caso d’uso: spesso bastano e risparmi un’intera dipendenza.

🎯 Quick win: Se il tuo sito usa un font system esterno (Google Fonts, Adobe Fonts), considera di aggiungere skeleton anche per il testo durante il FOIT/FOUT (Flash of Invisible/Unstyled Text). Un semplice font-display: swap nel @font-face più uno skeleton sul titolo principale elimina la sensazione di “salto visivo” nel caricamento iniziale.

Condividi

Articoli Recenti

Categorie popolari