Si tu as déjà ship un hero en height: 100vh ou un “app shell” plein écran sur iPhone, tu connais la scène. Tu scrolles un poil, la barre d’adresse Safari se rétracte, et ton layout… saute. Parfois ça crée un gap en bas. Parfois ça décale un footer fixe. Parfois ton overlay ne couvre plus tout. Et en QA, ça ressemble à un bug intermittent bien relou.
Ce n’est pas intermittent. C’est juste que sur iOS Safari, “100vh” n’est pas une hauteur stable tant que les barres du navigateur peuvent apparaître/disparaître. Le vrai fix en 2026, c’est d’arrêter d’espérer que vh fasse le job et de passer sur svh/dvh (avec un fallback correct si tu dois supporter des vieux iOS).
Reproduire le bug (pour être sûr qu’on parle du même)
Tu prends une page avec un bloc full-height (hero, menu, modal, ou juste un container qui doit remplir l’écran) :
Tu mets min-height: 100vh ou height: 100vh. Sur desktop, tout va bien. Sur Android Chrome, souvent ça passe. Sur iOS Safari, ouvre la page, puis fais un scroll léger vers le bas. La barre du bas et/ou la barre d’adresse se contractent. Le viewport visible grandit, et Safari “recompose” la page. Ton bloc full-height se retrouve soit trop grand, soit trop petit, et tu vois un espace vide ou un décalage.
Ce qui rend ça vicieux, c’est que ça dépend du moment où Safari décide de “stabiliser” ses barres, de la direction du scroll, et même du fait que tu sois en navigation “classique” ou dans un webview. Tu as l’impression que ton CSS a une humeur. En réalité, c’est juste le viewport qui change sous tes pieds.
Sponsorisé par Le Scribouillard
Besoin de contenu optimisé SEO ?
Utilisez la meilleure plateforme française de création de contenu assistée par IA ! Et générez des articles pour moins de 1€ !
Pourquoi 100vh “ment” sur iOS Safari
vh est une unité basée sur le viewport. Sauf que sur mobile, il n’y a pas un seul viewport “évident”. Il y a le viewport “théorique” (la zone que le navigateur considère comme la page) et le viewport réellement visible (ce que l’utilisateur voit, sans les barres du navigateur).
Sur iOS Safari, pendant longtemps, 100vh a eu tendance à correspondre à une hauteur qui ne match pas toujours le viewport visible. Résultat : tu cales ton layout sur une valeur qui peut être trop grande quand les barres sont affichées, puis tout se recale quand elles se cachent. D’où l’effet “ça saute”, ou le fameux “ugly space” en bas.
Le point important à retenir : ton problème n’est pas “le scroll”. Ton problème, c’est que tu as basé un élément critique sur une unité qui n’est pas conçue pour être stable quand l’UI du navigateur change.
La vraie différence entre vh, svh, dvh et lvh
Les nouvelles unités de viewport (CSS Values & Units Level 4) existent pour arrêter cette ambiguïté. En gros, on te donne trois “versions” du viewport :
svh (small viewport height) correspond à la plus petite hauteur possible du viewport, typiquement quand les barres du navigateur sont visibles. C’est la valeur conservative. Si tu veux éviter que ton contenu se retrouve caché derrière une barre, svh est souvent un bon choix.
dvh (dynamic viewport height) suit la hauteur visible réelle, en tenant compte du fait que les barres apparaissent/disparaissent. C’est la valeur “qui bouge”, mais au moins elle bouge dans le bon sens. Pour des éléments qui doivent coller exactement à ce que l’utilisateur voit (un app shell, un overlay), c’est généralement ce que tu veux.
lvh (large viewport height) correspond à la plus grande hauteur possible, typiquement quand les barres sont rétractées. Ça peut être utile dans des cas précis (par exemple un visuel qui peut s’étendre quand l’UI disparaît), mais c’est rarement ce que tu veux pour un layout “fonctionnel” parce que ça peut créer du contenu hors écran quand les barres reviennent.
Et vh ? Sur beaucoup de navigateurs modernes, il se rapproche de lvh ou d’un comportement historique pas très adapté aux barres mobiles. Donc oui, on peut dire que vh est devenu l’unité “floue” à éviter pour du plein écran mobile.
Pattern 1 : le layout full-height (hero, app shell) qui ne doit pas sauter
Le cas le plus fréquent, c’est un écran qui doit remplir la hauteur disponible sans créer de gap, et sans que l’utilisateur ait l’impression que l’UI respire n’importe comment. Typiquement : landing avec hero plein écran, webapp avec topbar et contenu scrollable, ou une page “onboarding”.
En pratique, je pars sur min-height plutôt que height si je ne suis pas sûr du contenu. Un hero “plein écran” qui coupe le texte parce que tu as un iPhone SE en police énorme, ça ne rend service à personne.
/* Fallback simple, puis unités modernes quand dispo */
.hero {
min-height: 100vh;
min-height: 100svh; /* stable quand les barres sont visibles */
}
/* Si tu veux vraiment coller au viewport visible dynamique */
.app-shell {
min-height: 100vh;
min-height: 100dvh;
}
/* Quand tu as du “bottom UI” (CTA fixe, tab bar), pense safe-area */
.footer-fixed {
padding-bottom: env(safe-area-inset-bottom);
}
Le choix entre svh et dvh est un choix UX, pas juste un choix technique. svh évite des effets de “stretch” quand Safari se rétracte, donc c’est souvent plus stable visuellement. dvh colle à la réalité du viewport, donc c’est souvent mieux pour une app plein écran ou un écran “outil” où tu veux maximiser la place disponible.
Mon réglage par défaut aujourd’hui : hero marketing = svh (ça évite les sauts moches), app shell = dvh (ça colle au visible et ça évite les trous dans les overlays et panneaux).
Pattern 2 : modals, popovers, menus plein écran (le piège des position: fixed)
Les overlays sont le deuxième endroit où ça part en vrille, parce que tu combines souvent position: fixed, du blur, un backdrop, et un scroll lock. Sur iOS, tu peux te retrouver avec une modal qui dépasse, ou au contraire un espace vide en bas quand la barre bouge.
Le truc qui marche bien : ancrer l’overlay à inset: 0 et lui donner une hauteur basée sur dvh si tu veux couvrir exactement la zone visible.
Et surtout, ne fais pas confiance à un height: 100vh sur un fixed si tu vises iOS Safari. Le “viewport” pris en compte peut être différent de ce que tu imagines, et tu retombes dans l’effet “gap”.
Un autre piège très concret : le scroll lock. Sur iOS, si tu bloques le scroll du body n’importe comment, tu peux casser l’inertie, provoquer des rebounds, ou créer des comportements où la barre d’adresse se rétracte/ressort de manière plus agressive. Un overlay qui scrolle en interne (avec overflow: auto) est souvent plus stable qu’un body figé avec des hacks vieux de 2018.
Dernier détail qui pique : le safe-area. Si ta modal a un bouton en bas et que tu oublies env(safe-area-inset-bottom), tu vas le coller sous la home indicator. Les gens vont rater le CTA. Et tu vas te demander pourquoi “sur iPhone ça convertit moins”.
Pattern 3 : canvas, maps, vidéos, éléments “pixel-perfect” qui doivent matcher le parent
Là on sort du “layout sympa” et on rentre dans le concret qui casse : un <canvas> pour un jeu, un éditeur, une map, un composant WebGL, un viewer. Tu veux que l’élément fasse exactement la taille du container, ni plus ni moins, sinon tu as du flou, du stretching, ou des coordonnées de pointer qui ne matchent plus.
Le bon réflexe : arrêter de piloter ça avec vh et piloter ça avec le parent. Tu donnes au parent une hauteur en dvh/svh, et tu fais prendre au canvas width: 100%; height: 100%. Ensuite, tu synchronises la résolution interne du canvas (ses attributs width/height) avec sa taille CSS. Sinon, tu auras un rendu dégueu dès que le devicePixelRatio s’en mêle.
Et si tu as déjà vécu l’enfer des “touch events décalés” sur iOS, tu sais pourquoi je suis un peu sec là-dessus : si ton canvas n’est pas dimensionné sur une taille stable, tu vas courir après des bugs fantômes.
Les pièges qui reviennent en prod (et qui te font croire que c’est un bug Safari “random”)
position: fixed sur iOS a une longue histoire de comportements surprenants, surtout combiné avec des parents qui ont des transforms, des overflow, ou des scroll containers. Si ton fixed est dans un parent transformé, il n’est plus vraiment fixed au viewport. Et tu peux te retrouver avec des décalages qui ressemblent exactement au bug du 100vh, alors que tu as juste un contexte de stacking / containing block inattendu.
overflow: hidden partout pour “empêcher le scroll” est une arme à double tranchant. Ça peut stabiliser un écran, mais ça peut aussi forcer des scroll containers internes, et iOS gère parfois ces containers différemment (scroll bouncing, scrollbars, resize du viewport). Si tu as un layout qui saute uniquement quand un overlay est ouvert, c’est souvent là.
Les barres “safe area” ne sont pas un détail cosmétique. Dès que tu colles une UI en bas (tabs, CTA sticky, mini-player), tu dois intégrer env(safe-area-inset-bottom) dans la hauteur utile ou dans le padding. Sinon, tu vas “réparer” le gap du 100vh en créant un autre bug plus discret, mais plus méchant pour l’utilisateur.
Fallback JS minimal (quand tu dois supporter des iOS trop vieux)
Si tu as encore des devices qui ne supportent pas correctement dvh/svh (ou des webviews capricieux), le fallback classique consiste à créer une variable CSS basée sur la hauteur réelle, et à l’utiliser à la place de 100vh.
Ce n’est pas “plus propre” que le CSS moderne. C’est juste un bandage utile quand tu n’as pas le choix. L’idée est simple : tu calcules 1% de la hauteur visible et tu le stockes dans --vh, puis tu fais height: calc(var(--vh) * 100).
// Fallback vh iOS: calcule la hauteur visible et l'expose en CSS var.
// À garder minimal, et à tester sur ton parc réel.
function setVhVar() {
const vv = window.visualViewport;
const height = vv ? vv.height : window.innerHeight;
document.documentElement.style.setProperty('--vh', `${height * 0.01}px`);
}
setVhVar();
window.addEventListener('resize', setVhVar);
window.addEventListener('orientationchange', setVhVar);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', setVhVar);
window.visualViewport.addEventListener('scroll', setVhVar);
}
Et côté CSS :
Tu mets ton fallback height: calc(var(--vh, 1vh) * 100). Si la variable n’existe pas, tu retombes sur 1vh. Si elle existe, tu utilises la “vraie” hauteur mesurée. Ça ne règle pas tous les cas, mais ça règle la majorité des layouts qui “sautent”.
Je te le dis comme je le fais en équipe : si tu peux basculer sur dvh/svh, fais-le. Le JS fallback, c’est une dette. Pas énorme, mais une dette quand même. Tu vas finir par le trim, le retester, et le garder plus longtemps que prévu.
Mon avis terrain : arrête d’écrire 100vh par réflexe
En 2026, 100vh sur mobile, c’est comme position: sticky sans tester sur Safari à l’époque où c’était bancal. Ça peut marcher. Jusqu’au jour où ça ne marche pas, et tu te retrouves à patcher en urgence une régression “que personne ne comprend”.
Si tu fais du plein écran, choisis intentionnellement svh (stable) ou dvh (match le visible). Si tu fais des overlays, pense safe-area et évite les hacks de scroll lock qui mettent iOS en PLS. Et si tu as un canvas ou une map, pilote la taille par le parent, pas par un viewport unit flou.
Tu vas gagner du temps, et tu vas arrêter de prendre Safari pour un casino.