Touch Scroll Fix - Scroll su Dispositivi Mobile
Il Problema
Quando si usa SheetRoot di Radix UI (o qualsiasi Dialog/Sheet basato su Radix UI Dialog), su dispositivi touch (smartphone e tablet) lo scroll verticale del contenuto interno non funziona correttamente.
Sintomi
- ✅ Lo scroll funziona perfettamente su desktop (con mouse o trackpad)
- ❌ Su mobile, lo scroll touch non funziona o è molto difficoltoso
- ❌ Il contenuto lungo non è accessibile
- ❌ L'utente non può navigare nel menu/sidebar mobile
Causa Tecnica
Radix UI Dialog/Sheet applica gestori di eventi touch sull'overlay (il backdrop scuro) per implementare la funzionalità "chiudi al click fuori".
Problema:
Utente tocca il contenuto scrollabile
↓
Radix UI overlay intercetta l'evento touch
↓
L'evento non raggiunge il contenitore scrollabile
↓
❌ Scroll non funzionaIl problema è che i gestori touch dell'overlay interferiscono con lo scroll del contenuto interno, impedendo agli eventi touch di raggiungere il container scrollabile.
La Soluzione
Il fix richiede due proprietà CSS/JS applicate al contenitore scrollabile:
1. touchAction: 'pan-y'
style={{ touchAction: 'pan-y' }}Cosa fa:
- Forza il browser a permettere SOLO lo scroll verticale (
pan-y) - Impedisce gesti orizzontali (swipe per chiudere)
- Ha massima priorità se impostato come stile inline
Perché inline:
- Garantisce massima priorità CSS
- Compatibilità cross-browser ottimale
- Non può essere sovrascritto da classi CSS
2. onTouchMove con stopPropagation()
onTouchMove={(e) => e.stopPropagation()}Cosa fa:
- Impedisce agli eventi touch di "salire" (bubble up) fino all'overlay
- Blocca la propagazione, assicurando che il touch venga gestito SOLO dal contenitore scrollabile
- Permette lo scroll verticale fluido su tutti i dispositivi touch
Meccanismo:
Utente tocca e scrolla il contenuto
↓
onTouchMove intercetta l'evento
↓
stopPropagation() blocca la propagazione
↓
✅ L'overlay NON riceve l'evento
↓
✅ Scroll funziona perfettamenteImplementazione
Pattern Standard
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-scroll overscroll-contain p-4 [-webkit-overflow-scrolling:touch]"
style={{ touchAction: 'pan-y' }}
onTouchMove={(e) => e.stopPropagation()}
>
{/* Contenuto scrollabile */}
</div>Classi CSS importanti:
overflow-y-scroll- Abilita scroll verticaleoverscroll-contain- Previene scroll chaining (iOS)[-webkit-overflow-scrolling:touch]- Smooth scrolling su iOS
Dove È Applicato
1. Sidebar Mobile - SidebarContent
File: app/components/ui/sidebar.tsx:366-398
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
/*
* Fix per lo scroll touch su dispositivi mobile:
*
* 1. touchAction: 'pan-y' - Forza il browser a permettere SOLO lo scroll verticale.
* Questo previene che gesti orizzontali (come lo swipe per chiudere lo Sheet)
* interferiscano con lo scroll. Viene impostato come proprietà inline invece che
* classe CSS per garantire la massima priorità e compatibilità cross-browser.
*
* 2. onTouchMove con stopPropagation() - Impedisce agli eventi touch di "salire"
* (bubble up) fino all'overlay dello Sheet. Senza questo, l'overlay intercetta
* gli eventi touch e impedisce lo scroll del contenuto. stopPropagation() blocca
* la propagazione, assicurando che il touch venga gestito SOLO dal contenitore
* scrollabile, permettendo lo scroll verticale fluido su tutti i dispositivi touch.
*
* Questo fix è necessario perché Radix UI Dialog/Sheet (che è la base di Sheet)
* applica pointer-events e gestori touch sull'overlay per implementare la chiusura
* al click fuori, ma questo può interferire con lo scroll interno su touch devices.
*/
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
style={{ touchAction: 'pan-y' }}
onTouchMove={(e) => e.stopPropagation()}
{...props}
/>
)
}2. ModuleMenuMobile - Contenitore Scroll
File: app/components/layout/topbar/module-menu-mobile.tsx:150-167
<SheetRoot open={openMobile} onOpenChange={setOpenMobile}>
<SheetContent
side="left"
className="w-[280px] p-0 flex flex-col overflow-hidden"
hideCloseButton={true}
>
{/*
* Fix per lo scroll touch su dispositivi mobile:
*
* 1. touchAction: 'pan-y' - Forza il browser a permettere SOLO lo scroll verticale.
* Questo previene che gesti orizzontali (come lo swipe per chiudere lo Sheet)
* interferiscano con lo scroll. Viene impostato come proprietà inline invece che
* classe CSS per garantire la massima priorità e compatibilità cross-browser.
*
* 2. onTouchMove con stopPropagation() - Impedisce agli eventi touch di "salire"
* (bubble up) fino all'overlay dello Sheet. Senza questo, l'overlay intercetta
* gli eventi touch e impedisce lo scroll del contenuto. stopPropagation() blocca
* la propagazione, assicurando che il touch venga gestito SOLO dal contenitore
* scrollabile, permettendo lo scroll verticale fluido su tutti i dispositivi touch.
*
* Questo fix è necessario perché Radix UI Dialog/Sheet (che è la base di Sheet)
* applica pointer-events e gestori touch sull'overlay per implementare la chiusura
* al click fuori, ma questo può interferire con lo scroll interno su touch devices.
*/}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-scroll overscroll-contain p-4 [-webkit-overflow-scrolling:touch]"
style={{ touchAction: 'pan-y' }}
onTouchMove={(e) => e.stopPropagation()}
>
{renderMobileMenuItems(menuData)}
</div>
</SheetContent>
</SheetRoot>Test
Setup Test
Dispositivi:
- iPhone/iPad (Safari)
- Android phone/tablet (Chrome)
- Desktop con DevTools touch emulation
Procedura Test
Apri il componente mobile
- Sidebar mobile: Riduci finestra < 1024px, apri sidebar
- ModuleMenuMobile: Riduci finestra < 1024px, clicca icona Menu
Verifica contenuto scrollabile
- Assicurati che il contenuto sia più lungo della viewport
- Dovrebbero esserci almeno 10-15 voci nel menu
Test scroll touch
- Su dispositivo fisico: usa il dito per scrollare verticalmente
- Su desktop: usa DevTools touch emulation
- Verifica che lo scroll sia fluido e reattivo
Test overlay click-outside
- Clicca/tocca l'overlay scuro fuori dal contenuto
- Verifica che il Sheet si chiuda correttamente
Test swipe gesture (se abilitato)
- Prova a swipare orizzontalmente
- Verifica che lo swipe non interferisca con lo scroll verticale
Risultati Attesi
- ✅ Scroll verticale funziona perfettamente su tutti i touch devices
- ✅ Scroll è fluido e reattivo (nessun lag o blocco)
- ✅ Click outside chiude correttamente il Sheet
- ✅ Nessuna interferenza tra scroll verticale e gesti orizzontali
Troubleshooting
Scroll non funziona su iOS:
- Verifica che
-webkit-overflow-scrolling: touchsia applicato - Controlla che
overscroll-containsia presente
Scroll funziona ma è scattoso:
- Aggiungi
will-change: scroll-positional container - Verifica che non ci siano animazioni CSS pesanti durante lo scroll
Overlay non chiude il Sheet:
- Verifica che
stopPropagation()sia applicato SOLO aonTouchMove - NON applicare
stopPropagation()aonClickoonTouchEnd
Compatibilità
Browser Supportati
| Browser | Versione | Supporto | Note |
|---|---|---|---|
| Safari iOS | 12+ | ✅ Full | Richiede -webkit-overflow-scrolling |
| Chrome Android | 80+ | ✅ Full | Nativo |
| Firefox Android | 80+ | ✅ Full | Nativo |
| Edge Mobile | 80+ | ✅ Full | Nativo |
| Samsung Internet | 12+ | ✅ Full | Nativo |
CSS Properties
| Property | Support | Fallback |
|---|---|---|
touch-action: pan-y | ✅ iOS 13+, Android 4.4+ | Nessun fallback necessario |
-webkit-overflow-scrolling: touch | ✅ iOS 5-12 | Ignorato su iOS 13+ (momentum scroll è default) |
overscroll-contain | ✅ Chrome 63+, Safari 16+ | Degrada gracefully |
Alternative Considerate
❌ Solo touch-action: pan-y
Problema: Non sufficiente da solo
// Non funziona completamente
style={{ touchAction: 'pan-y' }}L'overlay di Radix UI continua a intercettare gli eventi anche con touch-action.
❌ Solo stopPropagation()
Problema: Non previene gesti orizzontali
// Comportamento inconsistente
onTouchMove={(e) => e.stopPropagation()}Senza touch-action: pan-y, lo swipe orizzontale può interferire.
❌ preventDefault() su touch events
Problema: Rompe lo scroll nativo
// ❌ NON FARE - Disabilita completamente lo scroll
onTouchMove={(e) => {
e.preventDefault();
e.stopPropagation();
}}preventDefault() disabilita lo scroll invece di facilitarlo.
✅ Soluzione Combinata (Scelta)
// ✅ Soluzione completa e funzionante
style={{ touchAction: 'pan-y' }}
onTouchMove={(e) => e.stopPropagation()}Le due proprietà insieme risolvono completamente il problema.
Best Practices
DO ✅
Applica entrambe le proprietà
typescriptstyle={{ touchAction: 'pan-y' }} onTouchMove={(e) => e.stopPropagation()}Usa stile inline per
touchAction- Garantisce massima priorità
- Non può essere sovrascritto da CSS esterni
Aggiungi classi iOS-specific
typescriptclassName="[-webkit-overflow-scrolling:touch] overscroll-contain"Documenta il fix nel codice
- Commenti inline spiegano il perché
- Aiuta futuri sviluppatori a non rimuoverlo
DON'T ❌
Non usare
preventDefault()typescript// ❌ Disabilita lo scroll onTouchMove={(e) => e.preventDefault()}Non applicare il fix all'overlay
typescript// ❌ Applicare al container scrollabile, NON all'overlay <SheetContent style={{ touchAction: 'pan-y' }}>Non omettere una delle due proprietà
typescript// ❌ Incompleto - serve sia touchAction che stopPropagation style={{ touchAction: 'pan-y' }} // Manca onTouchMove!Non usare classi CSS per
touchActiontypescript// ❌ Può essere sovrascritto className="touch-pan-y" // ✅ Usa inline style style={{ touchAction: 'pan-y' }}
Debugging
Console Logs
Aggiungi log temporanei per debug:
onTouchMove={(e) => {
console.log('[TouchScroll] Touch move event:', {
target: e.target,
propagationStopped: e.isPropagationStopped(),
});
e.stopPropagation();
}}Chrome DevTools
- Apri DevTools → Performance tab
- Abilita "Screenshots" e "Memory"
- Start recording
- Prova lo scroll touch
- Stop recording
- Analizza:
- Touch events nella timeline
- Scroll performance
- FPS durante lo scroll
Safari Web Inspector (iOS)
- Connetti iPhone/iPad al Mac
- Apri Safari → Develop → [Your Device]
- Inspect la pagina
- Nella console, verifica:javascript
// Verifica computed style getComputedStyle(document.querySelector('[data-slot="sidebar-content"]')).touchAction // Dovrebbe essere: "pan-y"