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:
- CSS puro: accordion con
<details>, tab con:target, menu con:hover/:focus-within. <script>+ web component: 15 righe, nessuna dipendenza, funziona su ogni istanza.- 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).
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.
| Direttiva | Quando idrata | Uso tipico |
|---|---|---|
client:load | subito | elementi above-the-fold che devono rispondere immediatamente |
client:idle | quando il browser è libero (requestIdleCallback) | interattività non urgente |
client:visible | quando entra nel viewport | tutto ciò che sta sotto la piega, widget pesanti |
client:media="(...)" | se la media query matcha | componenti legati al breakpoint |
client:only="react" | mai sul server, solo nel browser | librerie 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
.astronon si idratanoMettere
client:loadsu un componente.astroè un errore, non un no-op. I.astrosono 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.
5.4 Aggiungere un framework UI
npx astro add react # oppure svelte | vue | solid | preact | alpinejsOgni 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>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 importareViewTransitionsè 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 helperisTransition*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".
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
- Riscrivi un accordion prima con
<details>/<summary>e poi con un web component. Confronta il JavaScript spedito al browser. - Prendi un componente React qualsiasi, usalo senza direttiva e guarda il
sorgente della pagina: è HTML, e basta. Poi aggiungi
client:visiblee osserva in DevTools → Network quando parte il download. - Aggiungi
<ClientRouter />al layout. Trova cosa si rompe (quasi sicuramente uno script) e sistemalo conastro:page-load. - Implementa
TocAttivasulla guida del modulo 4.