05. Interattività: island, script e view transitions

← 04 - Contenuti - Markdown, MDX e content collections · Indice · successivo → 06 - Rendering on-demand - adapter, middleware, actions

5.1 Prima di tutto: ti serve davvero un framework?

Ordine di scelta, dal più economico al più costoso:

  1. CSS puro: accordion con <details>, tab con :target, menu con :hover/:focus-within.
  2. <script> + web component: 15 righe, nessuna dipendenza, funziona su ogni istanza.
  3. Isola di framework: quando ti serve una libreria React/Vue specifica, o uno stato complesso.

Per una guida o un sito di documentazione, il punto 3 nella maggior parte dei casi non arriva mai. Partire da React “perché lo conosco” è la ragione più comune per cui un sito Astro finisce per pesare quanto un sito Next.

5.2 <script> in Astro

Uno <script> senza attributi viene processato: è TypeScript di default, gli import vengono risolti e bundlati, diventa type="module", e, dettaglio importante, viene incluso una volta sola anche se il componente compare cinquanta volte nella pagina.

<button class="copia">Copia</button>
<script>
  document.querySelectorAll('button.copia').forEach((b) => {
    b.addEventListener('click', () => navigator.clipboard.writeText('...'));
  });
</script>

Regola precisa: Astro non processa un <script> che abbia un qualsiasi attributo diverso da src. Quindi is:inline è implicito ogni volta che aggiungi un attributo. Con is:inline lo script viene emesso letteralmente dove sta: niente TypeScript, niente import risolti, niente deduplicazione.

<script is:inline src="/analytics.js"></script>
<script is:inline src="https://cdn.example.com/lib.js"></script>

Passare dati dal server al browser

Il modo raccomandato sono gli attributi data-*, letti con dataset:

---
// src/components/Contatore.astro   ✅ verificato
interface Props { passo?: number }
const { passo = 1 } = Astro.props;
---
<astro-contatore data-passo={passo}>
  <button type="button">+</button> <span>0</span>
</astro-contatore>
 
<script>
  class AstroContatore extends HTMLElement {
    connectedCallback() {
      let n = 0;
      const passo = Number(this.dataset.passo ?? 1);
      const span = this.querySelector('span')!;
      this.querySelector('button')!.addEventListener('click', () => {
        n += passo;
        span.textContent = String(n);
      });
    }
  }
  customElements.define('astro-contatore', AstroContatore);
</script>

Perché questo pattern è buono: lo script resta bundlato e deduplicato (customElements.define viene eseguito una volta), ma ogni istanza ha i propri dati e il proprio stato. È interattività senza idratazione e senza framework: per menu, toggle, copia-negli-appunti, tab, filtri, lightbox è quasi sempre la risposta giusta.

Per payload strutturati grandi, l’idioma è un <script type="application/json"> letto con JSON.parse(document.getElementById(...).textContent).

📖 Client-side scripts

5.3 Le direttive client:*

Un componente React/Svelte/Vue senza direttiva viene renderizzato a HTML sul server e spedito senza una riga di JavaScript. Questo è il default, ed è il punto.

DirettivaQuando idrataUso tipico
client:loadsubitoelementi above-the-fold che devono rispondere immediatamente
client:idlequando il browser è libero (requestIdleCallback)interattività non urgente
client:visiblequando entra nel viewporttutto ciò che sta sotto la piega, widget pesanti
client:media="(...)"se la media query matchacomponenti legati al breakpoint
client:only="react"mai sul server, solo nel browserlibrerie che toccano window al primo render
<Carrello client:load />
<Accordion client:idle={{ timeout: 500 }} />
<Mappa client:visible={{ rootMargin: '200px' }} />
<MenuMobile client:media="(max-width: 50em)" />
<Grafico client:only="react">
  <div slot="fallback">Caricamento…</div>
</Grafico>

Due opzioni che vale la pena conoscere:

  • client:idle={{ timeout: 500 }}: idrata comunque dopo N ms, se il browser non trova mai un momento libero (succede su pagine con molte isole).
  • client:visible={{ rootMargin: '200px' }}: idrata prima che l’elemento entri in viewport. Elimina il “click morto” su mobile e riduce il layout shift.

I componenti .astro non si idratano

Mettere client:load su un componente .astro è un errore, non un no-op. I .astro sono template HTML puri, senza runtime client. Se ti serve interattività lì dentro, usi <script> (5.2).

Altre due regole: le props passate a un’isola devono essere serializzabili (oggetti semplici, number, string, Array, Map, Set, Date, URL, …): le funzioni non si possono passare; e le direttive funzionano solo su componenti importati direttamente in un file .astro.

📖 Directives reference

5.4 Aggiungere un framework UI

npx astro add react     # oppure svelte | vue | solid | preact | alpinejs

Ogni isola è un’app indipendente: due isole React sulla stessa pagina non condividono context né stato. Un file .astro può contenere componenti di framework diversi; un file .jsx no.

Children e slot, con una differenza da ricordare:

  • React/Preact/Solid: lo slot di default arriva come children; gli slot nominati diventano props in camelCase (slot="social-links" → props.socialLinks).
  • Svelte/Vue: <slot />, con il nome invariato.

Non puoi importare un .astro dentro un .jsx. Il giro è passarlo come slot da un file .astro:

<PannelloReact client:visible>
  <ContenutoAstro slot="corpo" />
</PannelloReact>

📖 Framework components

5.5 Stato condiviso tra isole: nanostores

Poiché ogni isola è isolata, per condividere stato serve qualcosa fuori da entrambe. La scelta raccomandata è nanostores: meno di 1 KB, zero dipendenze, agnostica rispetto al framework (una store può essere letta da un’isola React e una Svelte nella stessa pagina).

// src/stores/ui.js
import { atom, map } from 'nanostores';
 
export const menuAperto = atom(false);
export const filtri = map({ tag: null, ordine: 'recenti' });
import { useStore } from '@nanostores/react';
import { menuAperto } from '../stores/ui';
 
export default function BottoneMenu() {
  const aperto = useStore(menuAperto);
  return <button onClick={() => menuAperto.set(!aperto)}>Menu</button>;
}

Regole: useStore quando il valore va renderizzato, .get() dentro gli handler dove non serve un re-render. E soprattutto: nanostores non serve a passare dati dal frontmatter alle isole, per quello ci sono le props e i data-*.

📖 Sharing state between islands

5.6 View transitions e navigazione client

---
import { ClientRouter } from 'astro:transitions';
---
<head>
  <ClientRouter />
</head>

Il nome è cambiato due volte

<ViewTransitions /> è stato rinominato <ClientRouter /> in Astro 5 e rimosso del tutto in Astro 6. In Astro 7 importare ViewTransitions è un errore. Tutti i tutorial precedenti al 2025 usano il nome vecchio.

<ClientRouter /> fa due cose insieme: (a) intercetta i click sui link e sostituisce il DOM invece di ricaricare la pagina, (b) usa la View Transitions API nativa del browser per animare lo scambio. Dove l’API nativa non c’è, applica un fallback configurabile: <ClientRouter fallback="swap" /> (valori: animate di default, swap, none).

Direttive principali:

<aside transition:name="hero">          <!-- collega due elementi tra pagine -->
<header transition:animate="slide">      <!-- fade (default) | initial | slide | none -->
<video transition:persist />             <!-- sopravvive alla navigazione, con il suo stato -->
<Player client:load transition:persist transition:persist-props />

transition:persist è quello che permette il classico player audio che continua a suonare mentre navighi. Senza transition:persist-props, lo stato persiste ma le props vengono aggiornate dalla nuova pagina.

La trappola degli script con il router attivo

Gli script bundlati (il default) vengono eseguiti una volta sola: dopo una navigazione client il tuo addEventListener sul DOM vecchio non c’è più. La soluzione è registrare tutto dentro astro:page-load, che scatta sia al primo caricamento sia dopo ogni navigazione:

document.addEventListener('astro:page-load', () => {
  inizializzaMenu();
  inizializzaEvidenziazioneToc();
});

Eventi del ciclo di vita, in ordine: astro:before-preparation → astro:after-preparation → astro:before-swap → astro:after-swap → astro:page-load. Per il tema scuro senza flash, applicalo in astro:after-swap (prima del paint).

Rimosso in Astro 7

Le costanti TRANSITION_BEFORE_SWAP & co. e gli helper isTransition*Event() non esistono più: si usano direttamente le stringhe ('astro:before-swap').

Controllo del router: data-astro-reload su un link forza il ricaricamento pieno; data-astro-history="replace" sostituisce la voce nella cronologia; navigate() da astro:transitions/client naviga da codice.

Accessibilità: il router annuncia il cambio pagina alle tecnologie assistive usando <title> (poi il primo <h1>, poi il path): quindi ogni pagina deve avere un <title> sensato. E rispetta prefers-reduced-motion disattivando le animazioni.

Prefetch: attivando <ClientRouter /> il prefetch si attiva con { prefetchAll: true }. Senza router lo abiliti con prefetch: true in config e lo governi con data-astro-prefetch="hover|tap|viewport|load".

📖 View transitions

5.7 Un esempio completo: evidenziare la sezione attiva nella TOC

Mette insieme tutto il modulo: web component, data-*, IntersectionObserver, compatibilità con il router.

---
// src/components/TocAttiva.astro
---
<script>
  class TocAttiva extends HTMLElement {
    #osservatore?: IntersectionObserver;
 
    connectedCallback() {
      const link = new Map<string, HTMLAnchorElement>();
      this.querySelectorAll('a[href^="#"]').forEach((a) =>
        link.set(decodeURIComponent((a as HTMLAnchorElement).hash.slice(1)), a as HTMLAnchorElement)
      );
 
      this.#osservatore = new IntersectionObserver(
        (voci) => {
          for (const v of voci) {
            const a = link.get(v.target.id);
            if (a) a.classList.toggle('attivo', v.isIntersecting);
          }
        },
        { rootMargin: '0px 0px -70% 0px' }
      );
 
      document.querySelectorAll('article h2[id], article h3[id]')
        .forEach((h) => this.#osservatore!.observe(h));
    }
 
    disconnectedCallback() {
      this.#osservatore?.disconnect();
    }
  }
  customElements.define('toc-attiva', TocAttiva);
</script>
 
<toc-attiva><slot /></toc-attiva>

Nota disconnectedCallback(): senza, con <ClientRouter /> attivo accumuli observer a ogni navigazione. È il tipo di bug che non vedi finché il sito non rallenta dopo dieci pagine.

Esercizi

  1. Riscrivi un accordion prima con <details>/<summary> e poi con un web component. Confronta il JavaScript spedito al browser.
  2. Prendi un componente React qualsiasi, usalo senza direttiva e guarda il sorgente della pagina: è HTML, e basta. Poi aggiungi client:visible e osserva in DevTools → Network quando parte il download.
  3. Aggiungi <ClientRouter /> al layout. Trova cosa si rompe (quasi sicuramente uno script) e sistemalo con astro:page-load.
  4. Implementa TocAttiva sulla guida del modulo 4.