SVG dans PowerApps : Et l'accessibilité dans tout ça ?
TL;DR : L'usage du SVG est devenu un réflexe pour dynamiser nos Canvas Apps. Sauf qu'une partie de vos utilisateurs a demandé à son système d'arrêter les animations, et que Power Fx n'a aucun moyen de le savoir. Le CSS embarqué dans le SVG, si :
@media (prefers-reduced-motion: reduce)est bel et bien évalué à l'intérieur d'une Canvas App, à chaque ouverture de session. Voici le pattern prêt à coller, le banc d'essai qui le prouve, la raison précise pour laquelle cette lecture est un instantané et non un flux, et l'architecture en cascade qui en fait un pattern réellement utilisable en production.
Partie 1 sur 5 · 4 min de lecture
Le problème et la réponse
Le constat et le code qui le règle.
1. Une tendance et un angle mort
Les SVG sont pratiques, légers, nets à toutes les tailles, colorables avec Power Fx et au vu de la pléthore de ressources disponibles en ligne, nous avons de quoi faire.
Mais une tendance est progressivement apparue : le SVG animé. Un spinner qui tourne, un halo qui pulse sur la date du jour, une icône qui se dessine à l'ouverture de l'écran. Ça donne immédiatement un côté vivant à une app, pour un coût dérisoire de quelques lignes.
Sauf que pour une partie de vos utilisateurs, ce mouvement n'est pas une touche sympa mais une gêne physique. Troubles vestibulaires, sensibilité migraineuse, certains troubles de l'attention : ces personnes ont déjà dit à leur système d'exploitation qu'elles ne voulaient plus d'animations dans les paramètres de Windows et votre app ne les écoute pas.
Ce n'est pas une question de confort, c'est écrit dans les référentiels :
-
WCAG 2.2.2 : Mettre en pause, arrêter, masquer (niveau A) : tout contenu en mouvement qui démarre automatiquement et dure plus de 5 secondes doit pouvoir être mis en pause.
-
WCAG 2.3.3 : Animation par interaction (niveau AAA) : les animations non essentielles déclenchées par une interaction doivent pouvoir être désactivées.
Quand on parle d'accessibilité dans une Canvas App, on parle en général d'AccessibleLabel, de TabIndex (ordre de tabulation), de contraste et de Live regions. Tout ça est documenté, tout ça passe dans l'Accessibility Checker. Le mouvement, lui, n'est nulle part. Il n'existe pas davantage dans Power Fx : il y a Host.OSType, Host.BrowserUserAgent, App.Width... et rien du tout sur les préférences d'accessibilité du système. Aucun Host.PrefersReducedMotion.
Concrètement : votre Timer qui fait glisser un panneau, votre Transition sur un bouton, votre badge qui clignote, ils tournent, quoi que l'utilisateur ait demandé à son OS. Et vous n'avez, en apparence, aucun moyen de le savoir.
Bonne nouvelle : vous n'avez pas besoin de Host.PrefersReducedMotion. La préférence est déjà là, à l'intérieur même de vos SVG. Il suffit de trois lignes de CSS.
2. La réponse tient en trois lignes de CSS
Prenons un cas réel. Dans mon composant cmpCalendar, la date du jour porte un halo qui pulse doucement. Voici la version qui écoute le système :
"data:image/svg+xml;utf8," & EncodeUrl(
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'>" &
"<style>" &
".halo{fill:none;stroke:#0078d4;stroke-width:2;transform-origin:20px 20px;" &
"animation:pulse 1.8s ease-out infinite}" &
"@keyframes pulse{from{transform:scale(.78);opacity:.9}to{transform:scale(1.12);opacity:0}}" &
"@media (prefers-reduced-motion:reduce){" &
".halo{animation:none;opacity:1;stroke-width:2.5}" &
"}" &
"</style>" &
"<circle class='halo' cx='20' cy='20' r='17'/>" &
"</svg>"
)Par défaut : un halo qui respire. Sous reduce : un anneau net, immobile, légèrement plus épais pour rester tout aussi visible. L'information "c'est aujourd'hui" est intacte dans les deux cas. Aucun If() dans la formule, aucune variable, aucune logique Power Fx : c'est le CSS qui arbitre.
Lecture de votre préférence système...
no-preferencevotre rendu
Le halo respire. stroke-width:2
reducevotre rendu
Anneau net, immobile. stroke-width:2.5
Il manque une ligne pour que ce soit fiable, et une seule. Dans App.OnStart :
// App.OnStart
Set(gblBust, GUID())Cette variable n'apparaît pas dans le code ci-dessus, mais elle doit y être. J'explique en section 8 pourquoi elle n'est pas un artefact de debug mais le mécanisme de lecture lui-même.
C'est tout. Pas de connecteur, pas de PCF, pas de composant. Une chaîne de caractères.
Maintenant, pourquoi ça marche.
Partie 2 sur 5 · 7 min de lecture
Pourquoi ça marche
Cette partie explique par quelle porte le CSS entre dans une Canvas App, et pourquoi la voie qui semble évidente, la balise <style> du contrôle Texte HTML, n'en est pas une.
3. L'angle mort technique : il y a déjà un moteur CSS dans votre Canvas App
Une Canvas App, c'est du web. Ça tourne dans Edge, dans Chrome, dans Teams (WebView2), dans iOS (WKWebView). Il y a donc, quelque part sous vos contrôles, un moteur de rendu complet avec un moteur CSS complet.
Le problème, c'est qu'on n'y a pas accès... sauf par une porte : une image peut être un data URI, et un data URI peut contenir du SVG, et un SVG peut contenir une balise <style>.
C'est le point clé. Un SVG affiché en tant qu'image est rendu dans ce que les navigateurs appellent le mode "statique sécurisé" :
| Ce qui est désactivé dans l'image | Ce qui reste actif dans l'image |
|---|---|
JavaScript, <script> |
Le CSS complet : sélecteurs, classes, cascade |
Ressources externes (fetch, polices distantes, <image href> distant) |
@keyframes et les animations CSS |
Interactivité, événements, liens : un <a> placé dans le SVG n'est pas cliquable |
@media, y compris les media features de préférence utilisateur |
| Toute remontée d'information vers la page hôte | Les transformations, opacités, filtres |
Autrement dit : c'est une sandbox parfaitement étanche... qui a quand même le droit de poser des questions au système d'exploitation.
Et c'est exactement la "faille" dont on a besoin. Parce que les media features dites de préférence utilisateur (prefers-reduced-motion, prefers-color-scheme, forced-colors, prefers-contrast) ne dépendent pas du document hôte : elles sont fournies par l'agent utilisateur lui-même, qui les tient du système. Une image SVG isolée y a donc accès au même titre qu'une page complète.
Restait à le vérifier. Parce que "ça devrait marcher d'après la spec" et "ça marche dans PowerApps sur le poste d'un utilisateur" sont deux affirmations très différentes. C'est l'objet de la section 7.
4. Pourquoi pas une balise <style> dans le contrôle Texte HTML ?
Cette question est logique puisque PowerApps possède un contrôle qui accepte du HTML brut et celui-ci accepte la balise <style>. Dès lors, pourquoi passer par un data URI dans un contrôle Image ?
La réponse tient en deux temps : la balise <style> ne survit pas, et pourtant ce contrôle peut porter une media query.
4.1. Ce qui ne marche pas : la feuille de style du contrôle
Le contrôle Texte HTML passe son contenu dans un assainisseur, qui retire la balise <style>. Ce qui reste exploitable est le style en ligne, style="...".
Encore faut-il être précis sur ce que "retire" veut dire, parce que la formulation courante est en dessous de la réalité. Je l'ai mesuré en app publiée, avec le canari de la section 7.2 : j'ai collé dans le contrôle une feuille contenant un sélecteur de classe ordinaire et un @media (min-width:1px), vrai partout où du CSS est évalué. Aucun des deux ne s'allume. Il ne s'agit donc ni de fonctions avancées désactivées, ni d'un tri entre les règles : il ne reste aucune feuille de style.
Autrement dit, ceci n'est pas un moyen d'ajouter une media query au contrôle :
<style>
@media (prefers-reduced-motion: reduce) {
.halo { animation: none }
}
</style>Cette balise ne parvient jamais au moteur de rendu.
4.2. Et le style en ligne ?
On peut vouloir contourner l'obstacle en posant le CSS directement sur les éléments :
<div style="animation: pulse 1.8s infinite">...</div>Ça règle la question du style, pas la nôtre. Un attribut style ne contient qu'une liste de déclarations propriété: valeur séparées par des point-virgules. Une media query, elle, est une at-rule :
(prefers-reduced-motion: reduce) {
.halo { animation: none }
}Cette syntaxe n'a aucune place dans un attribut. Même avec un assainisseur totalement permissif, même si Microsoft laissait passer les balises <style> demain matin, un style en ligne resterait incapable de porter un @media. Ce n'est pas une restriction de PowerApps, c'est une propriété de CSS.
Nous constatons donc deux obstacles de natures très différentes : le premier vient de la plateforme et pourrait évoluer un jour, le second vient du langage et n'évoluera pas.
À ce stade, on conclurait volontiers que le contrôle Texte HTML est incapable d'héberger une media query. Ce serait aller trop vite.
4.3. Ce qui marche : la feuille de style voyage dans l'image
Le déclic vient d'un détail qu'on oublie : ce contrôle ne reçoit pas que du texte. Il reçoit du HTML, et le HTML comprend la balise <img>. Or la source d'une image peut être un data URI, et un data URI peut contenir un document SVG complet, avec sa propre feuille de style. Le bloc ci-dessous n'est pas une formule à coller : c'est le document que votre formule produira, montré déplié pour qu'on y voie la feuille de style. C'est le halo de la section 2 ; la formule qui le fabrique est donnée en fin de section.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<style>
.halo { fill: none; stroke: #0078d4; stroke-width: 2;
transform-origin: 20px 20px;
animation: pulse 1.8s ease-out infinite }
@keyframes pulse { from { transform: scale(.78); opacity: .9 }
to { transform: scale(1.12); opacity: 0 } }
@media (prefers-reduced-motion: reduce) {
.halo { animation: none; opacity: 1; stroke-width: 2.5 }
}
</style>
<circle class="halo" cx="20" cy="20" r="17"/>
</svg>Emboîté dans le contrôle, ce document devient la source d'une image. Il y tient alors sur une seule ligne, encodé. Voici la balise obtenue, abrégée ici pour la lisibilité :
<img src="data:image/svg+xml;utf8,%3Csvg%20xmlns=%27...%27%3E%3Cstyle%3E...%3C/style%3E%3C/svg%3E">Les chevrons du SVG y sont encodés en %3C et %3E, les apostrophes en %27 : la chaîne ne contient plus aucun caractère capable de refermer l'attribut par accident. Vous n'aurez jamais à écrire cette chaîne vous-même : c'est EncodeUrl qui la produit, dans la formule donnée en fin de section.
C'est là que tout bascule. Le <style> n'est plus une balise du HTML soumis à l'assainisseur. Il se trouve à l'intérieur du document SVG, lui-même à l'intérieur de la source de l'image.
L'assainisseur ne lit que le balisage extérieur. Il voit une balise <img> dont la source est un data URI, il la laisse passer, et il n'ouvre jamais ce qu'elle contient. Le SVG arrive donc au moteur de rendu avec son document, sa feuille de style et sa media query intacts.
La règle générale, qui vous servira au-delà de ce sujet : le balisage vit, sa feuille de style non.
| Dans le contrôle Texte HTML | Media query ? | Pourquoi |
|---|---|---|
<style> ... </style> |
Non | la feuille de style est retirée par l'assainisseur |
style="..." |
Non | la grammaire CSS interdit une at-rule dans un attribut |
<img src="data:image/svg+xml,..."> |
Oui | le SVG embarque son propre document, donc sa propre feuille de style |
Ce qui change complètement la façon de voir le problème. Le point décisif n'est pas le contrôle Image, c'est le data URI. Le contrôle Image n'est que le chemin le plus court pour s'en servir.
Rien n'empêche donc de faire cohabiter les deux dans le même contrôle. Voici le montage complet, à coller dans la propriété HtmlText :
// Propriété HtmlText d'un contrôle Texte HTML
"<div style='font-family:Segoe UI,sans-serif'>" &
"<p>Votre texte, vos liens, votre mise en forme.</p>" &
"<img src='data:image/svg+xml;utf8," &
EncodeUrl(
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40' data-build='" & gblBust & "'>" &
"<style>" &
".halo{fill:none;stroke:#0078d4;stroke-width:2;transform-origin:20px 20px;" &
"animation:pulse 1.8s ease-out infinite}" &
"@keyframes pulse{from{transform:scale(.78);opacity:.9}to{transform:scale(1.12);opacity:0}}" &
"@media (prefers-reduced-motion:reduce){" &
".halo{animation:none;opacity:1;stroke-width:2.5}" &
"}" &
"</style>" &
"<circle class='halo' cx='20' cy='20' r='17'/>" &
"</svg>"
) & "'>" &
"</div>"Le HTML extérieur porte alors ce qui appartient vraiment au HTML : du texte, de la mise en forme, des liens. Le SVG porte le rendu graphique et la media query. Une précision s'impose cependant sur l'interactivité, parce qu'elle se paie. Le contenu affiché est bel et bien vivant : mesuré en app publiée, un lien ouvre une fenêtre, une case à cocher se coche, un bloc <details> se déplie. Mais il est muet. Rien de tout cela n'atteint Power Fx, qui ne saura jamais que la case est cochée. Le contenu vit, mais il ne parle pas.
Le seul pont vers l'application est l'OnSelect du contrôle. Il existe et il se déclenche, mais c'est l'événement du contrôle, pas celui de son contenu : il est unique et global, et rien dans Power Fx ne dit où le clic a eu lieu. Autant de zones réactives que l'on veut, autant de contrôles à poser. Dans un composant de graphe, le montage utile reste donc le même : le tracé et son comportement visuel vivent dans l'image, et l'interactivité que l'image ne sait pas fournir vient de Power Apps, posée autour d'elle ou par-dessus.
Et ce n'est pas une hypothèse. La section 7 construit un banc d'essai qui éprouve chaque maillon : le canari montre que le CSS du SVG est bien évalué, puis les sondes prefers-color-scheme et prefers-reduced-motion montrent que les media queries de préférence utilisateur répondent elles aussi. Ce banc est écrit pour la propriété Image, le chemin le plus court ; c'est le même SVG que j'ai posé dans le src d'une <img> pour éprouver le contrôle Texte HTML, et la section 9 en rend compte.
Partie 3 sur 5 · 5 min de lecture
Le faire proprement en production
Le mécanisme est acquis. Reste à s'en servir sans dégrader l'expérience : ce qu'il faut mettre à la place du mouvement, et comment laisser malgré tout le dernier mot à l'utilisateur.
5. Réduire n'est pas supprimer
L'erreur classique est de traiter prefers-reduced-motion: reduce comme un interrupteur général : animation: none partout, et on n'en parle plus. C'est un contresens. Une animation porte souvent de l'information, "ça charge", "c'est ici que ça vient de changer", "cet élément est actif".
Si vous la supprimez sans rien mettre à la place, vous ne rendez pas l'app plus accessible : vous en retirez une couche d'information à un public qui en a déjà besoin.
La bonne règle : remplacer le mouvement par un équivalent statique qui porte la même information. Un halo qui pulse devient un anneau plein. Un slide devient un fondu (les changements d'opacité et de couleur sont bien mieux tolérés que les grands déplacements). Une durée longue devient une durée courte.
C'est ce que fait le halo de la section 2, avec son stroke-width qui passe de 2 à 2,5.
5.1. Le cas limite : le chargeur
Même principe, autre usage :
"data:image/svg+xml;utf8," & EncodeUrl(
"<svg xmlns='http://www.w3.org/2000/svg' width='48' height='48' viewBox='0 0 48 48'>" &
"<style>" &
".ring{fill:none;stroke:#0078d4;stroke-width:4;stroke-linecap:round;" &
"stroke-dasharray:82 44;transform-origin:24px 24px;" &
"animation:spin 900ms linear infinite}" &
"@keyframes spin{to{transform:rotate(360deg)}}" &
"@media (prefers-reduced-motion:reduce){" &
".ring{animation:none;stroke-dasharray:none;opacity:.35}" &
"}" &
"</style>" &
"<circle class='ring' cx='24' cy='24' r='19'/>" &
"</svg>"
)Attention ici : un spinner immobile ne communique plus "en cours". En complément de ce SVG, c'est un vrai texte de statut à côté, dans un Label PowerApps, avec Live: Assertive, qui annonce "Chargement..." puis "Terminé". C'est de toute façon ce qu'il fallait faire pour les lecteurs d'écran. Le mode réduit ne fait que rendre l'omission visible.
6. Le pattern à trois modes
Les deux exemples ci-dessus sont bons pour illustrer, mais ils laissent une objection légitime sans réponse : et si l'utilisateur veut décider pour cette application précise ? Peut-être qu'il a coupé les animations partout dans Windows mais qu'il tolère les vôtres. Peut-être l'inverse : Windows ne dit rien, mais votre app en fait trop à son goût.
Voici la forme que je recommande en production. Une variable Power Fx à trois valeurs arbitre, et le CSS ne décide que dans un seul des trois cas.
"data:image/svg+xml;utf8," & EncodeUrl(
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40' " &
"class='" & Switch(gblMotionMode, "reduce", "red", "full", "ful", "sys") & "' " &
"data-build='" & gblBust & "'>" &
"<style>" &
".halo{fill:none;stroke:#0078d4;stroke-width:2.5;transform-origin:20px 20px}" &
"@keyframes pulse{from{transform:scale(.78);opacity:.9}to{transform:scale(1.12);opacity:0}}" &
".ful .halo{stroke-width:2;animation:pulse 1.8s ease-out infinite}" &
"@media (prefers-reduced-motion:no-preference){" &
".sys .halo{stroke-width:2;animation:pulse 1.8s ease-out infinite}" &
"}" &
"</style>" &
"<circle class='halo' cx='20' cy='20' r='17'/>" &
"</svg>"
)Trois modes, trois comportements :
gblMotionMode |
Classe | Comportement |
|---|---|---|
"system" ou vide |
sys |
le CSS décide, via la media query |
"reduce" |
red |
anneau immobile, quoi qu'en dise Windows |
"full" |
ful |
animation, quoi qu'en dise Windows |
Quatre points de conception méritent quelques explications, parce qu'ils sont contre-intuitifs :
L'état de base est statique. .halo seul ne porte aucune animation. Celle-ci est ajoutée par .ful, ou par .sys sous media query. C'est l'inverse de ce qu'on écrit spontanément (déclarer l'animation, puis l'annuler avec animation: none dans un bloc reduce), et c'est l'inverse de ce que fait le halo de la section 2. La version inversée est plus sûre : si le CSS ne s'évalue pas, si la classe n'arrive pas, si gblMotionMode est vide, le résultat est l'anneau immobile. Le comportement sûr devient le comportement par défaut, tandis que l'animation n'est activée que si elle est explicitement demandée. Pour illustrer un principe, on veut voir l'annulation ; en production, on préfère qu'un échec reste silencieux et sans conséquence.
Le Switch a un cas par défaut. Sa dernière valeur, "sys", s'applique si gblMotionMode est vide, ce qui est l'état d'un utilisateur qui n'a jamais rien réglé. Le comportement par défaut de l'application est donc "suivre le système". C'est le bon défaut, et il faut le choisir consciemment : un If() booléen écrit à la va-vite renverrait Blank() sur la branche else et vous donnerait exactement l'inverse de ce que vous vouliez.
Le sélecteur descendant fonctionne parce que la racine <svg> est bien un ancêtre du <circle>. C'est un point qui prête souvent à confusion, mais le fonctionnement est ici tout à fait classique.
reduce ne veut pas dire "rien". Notez le stroke-width : 2 en mode animé, 2,5 en mode immobile. C'est la règle de la section 5, appliquée.
Et côté Power Fx :
Set(gblBust, GUID());
Set(
gblMotionMode,
Coalesce(
LookUp(Preferences, Utilisateur = User().Email, ModeAnimation),
"system"
)
)Un simple Radio ou Dropdown à trois options dans votre écran de préférences, un Patch vers votre liste, et vous donnez le contrôle à l'utilisateur sans jamais lui imposer de configurer quoi que ce soit.
Choisissez un mode : l'aperçu se recompose exactement comme le ferait gblMotionMode dans votre application.
Partie 4 sur 5 · 12 min de lecture
La preuve
Tout ce qui précède repose sur une affirmation qu'il fallait vérifier plutôt que croire. C'est la partie la plus longue, et la plus facile à sauter : si vous me croyez sur parole, passez directement à la partie 5. Si vous voulez au contraire le banc d'essai à coller chez vous, c'est ici.
7. "Mais est-ce que ça marche vraiment ?" : le banc d'essai
Tout ce qui précède repose sur une affirmation : la media query est évaluée à l'intérieur d'une image SVG affichée par un contrôle PowerApps. Voici comment je l'ai vérifié, et comment vous pouvez le vérifier sur vos propres hôtes en trente secondes.
La formule complète, à coller dans la propriété Image d'un contrôle Image :
"data:image/svg+xml;utf8," & EncodeUrl(
"<svg xmlns='http://www.w3.org/2000/svg' width='600' height='340' viewBox='0 0 600 340' data-build='" & gblBust & "'>" &
"<style>" &
"text{font-family:system-ui,-apple-system,sans-serif;font-size:19px}" &
".sm{font-size:15px;fill:#8a8886}" &
".a0{opacity:1;fill:#d13438}.a1{opacity:0;fill:#107c10}" &
".b0{opacity:1;fill:#d13438}.b1{opacity:0;fill:#107c10}.b2{opacity:0;fill:#107c10}" &
".c0{opacity:1;fill:#d13438}.c1{opacity:0;fill:#8a8886}.c2{opacity:0;fill:#0078d4}" &
".mv{fill:#d13438;animation:mv 1.2s linear infinite alternate}" &
"@keyframes mv{from{transform:translateX(0)}to{transform:translateX(480px)}}" &
"@media (min-width:1px){.a0{opacity:0}.a1{opacity:1}}" &
"@media (prefers-color-scheme:light){.b0{opacity:0}.b1{opacity:1}}" &
"@media (prefers-color-scheme:dark){.b0{opacity:0}.b2{opacity:1}}" &
"@media (prefers-reduced-motion:no-preference){.c0{opacity:0}.c1{opacity:1}}" &
"@media (prefers-reduced-motion:reduce){.c0{opacity:0}.c2{opacity:1}.mv{fill:#0078d4;animation:none}}" &
"</style>" &
"<rect class='mv' x='20' y='20' width='45' height='45' rx='8'/>" &
"<text class='a0' x='20' y='130'>1. moteur CSS : inactif</text>" &
"<text class='a1' x='20' y='130'>1. moteur CSS : actif</text>" &
"<text class='b0' x='20' y='175'>2. prefers-color-scheme : non evalue</text>" &
"<text class='b1' x='20' y='175'>2. prefers-color-scheme : light</text>" &
"<text class='b2' x='20' y='175'>2. prefers-color-scheme : dark</text>" &
"<text class='c0' x='20' y='220'>3. prefers-reduced-motion : non evalue</text>" &
"<text class='c1' x='20' y='220'>3. prefers-reduced-motion : no-preference</text>" &
"<text class='c2' x='20' y='220'>3. prefers-reduced-motion : reduce</text>" &
"<text class='sm' x='20' y='300'>build : " & gblBust & "</text>" &
"</svg>"
)Et dans le OnStart de l'app (ou le OnVisible de l'écran, ou le OnSelect d'un bouton "Rafraîchir") :
Set(gblBust, Text(Now(), "[$-en-US]yyyymmddhhmmssfff"))Un horodatage plutôt qu'un GUID() ici, est délibéré : le banc affiche cette valeur en bas de l'image, et lire l'heure du rendu évite de conclure sur un écran périmé. En production, où elle est invisible, GUID() fera mieux, la section 8 explique pourquoi. Quant au préfixe [$-en-US], il n'est pas décoratif non plus : la section 10 lui est consacrée.
Le code a l'air d'un empilement de classes CSS un peu étrange mais en réalité chaque bloc répond à une question de méthode précise.
7.1. Le principe : trois états, dont un état "je ne sais pas"
Toute l'astuce tient dans un choix de conception : chaque sonde possède un état par défaut visible, qui signifie "non évalué".
Prenons la sonde 3 :
-
.c0, "non évalué" :opacity: 1par défaut, donc visible. -
.c1, "no-preference" :opacity: 0par défaut, révélée uniquement par la media query correspondante. -
.c2, "reduce" : idem.
Les trois textes sont superposés exactement au même endroit (x='20' y='220'). Un seul est visible à la fois. Le texte affiché est le résultat.
Pourquoi ce troisième état ? Parce que sans lui, on ne peut pas distinguer deux situations radicalement différentes :
-
la media query est supportée et retourne
false; -
la media query n'est pas supportée du tout, ou le CSS ne s'exécute pas.
Dans les deux cas, l'écran n'afficherait rien de spécial. C'est un contrôle négatif : sans lui, l'expérience ne prouve rien. Avec lui, un "non evalue" qui reste affiché en rouge est une information exploitable, pas un silence ambigu.
C'est le même réflexe qu'un test unitaire qui doit d'abord échouer pour qu'on sache qu'il teste vraiment quelque chose.
7.2. La sonde 1 : le canari
(min-width:1px){.a0{opacity:0}.a1{opacity:1}}Celle-ci ne teste aucune préférence. min-width: 1px est vraie dans tous les cas où le CSS est évalué (le SVG fait 600 px de large : pour cette condition, le navigateur se base sur la largeur du SVG lui-même, pas sur celle de l'écran.).
C'est un canari. Si "moteur CSS : actif" s'affiche en vert, on sait que :
-
le data URI a été correctement décodé ;
-
la balise
<style>a été parsée ; -
les sélecteurs de classe fonctionnent ;
-
les media queries sont évaluées.
Si ce premier voyant reste rouge, inutile d'interpréter les deux suivants : le problème est en amont, dans l'encodage ou dans le rendu. Cela évite des heures de fausses pistes du type "prefers-reduced-motion ne marche pas dans PowerApps" alors qu'en réalité il manquait juste un EncodeUrl.
Point important pour interpréter les résultats : ce canari ne prouve rien sur les préférences utilisateur. Il reste vert même si le moteur refuse de résoudre prefers-reduced-motion à partir des préférences système. C'est bien pour ça qu'il faut les trois sondes, et pas seulement celle qui nous intéresse.
7.3. Les sondes 2 et 3 : les vraies questions
Elles suivent exactement le même moule, avec deux valeurs positives chacune. Notez que pour prefers-reduced-motion, on interroge explicitement les deux valeurs, no-preference et reduce, plutôt que d'écrire seulement @media (prefers-reduced-motion: reduce). Encore une fois : on veut pouvoir distinguer "le système répond no-preference" de "personne ne répond".
Une précision de rigueur sur la sonde 2 : no-preference n'est plus une valeur de prefers-color-scheme, aucun navigateur ne l'ayant implémentée, celle-ci a été retirée de la spécification. La valeur light couvre à la fois le cas où l'utilisateur préfère explicitement le thème clair et celui où aucune préférence active n'est exprimée. La sonde .b1 ne permet donc pas de distinguer ces deux situations : elle s'affiche dans les deux cas.
7.4. Le carré rouge : la démonstration en plus de la mesure
.mv{fill:#d13438;animation:mv 1.2s linear infinite alternate}
mv{from{transform:translateX(0)}to{transform:translateX(480px)}}Ce petit carré fait des allers-retours sur 480 px. Sous prefers-reduced-motion: reduce, il devient bleu et s'immobilise.
Ce n'est pas de la décoration. Le texte prouve que la media query est évaluée ; le carré prouve que l'animation CSS s'exécute réellement dans ce contexte et qu'elle obéit à la règle. C'est ce qui fait passer le résultat de "intéressant" à "exploitable" : il ne suffit pas que le navigateur sache répondre à la question, il faut aussi que le moteur d'animation tourne dans un SVG rendu en tant qu'image.
Sur un banc d'essai, cette écriture est la bonne : elle rend l'annulation visible. Dans du code de production, on l'inverse : c'est le pattern de la section 6.
7.5. Le résultat
Sur mon poste (Windows 11, PowerApps dans Chrome), les trois sondes répondent, et le carré s'immobilise quand je coupe les effets d'animation dans Windows et que je relance la lecture. Ce dernier point n'est pas un détail : sans relancer, rien ne bouge, et c'est tout l'objet des sections 8 et 9.
prefers-reduced-motion fonctionne dans PowerApps.
reduce et le carré s'immobilise en bleu.
8. gblBust n'est pas un artefact de test
C'est, de très loin, le point qui m'a coûté le plus de temps. Il est en réalité le mécanisme de lecture lui-même.
Le data URI est l'URL de l'image. Si la chaîne produite est identique octet pour octet, le navigateur renvoit l'image déjà décodée depuis son cache mémoire. Alors que c'est au moment du décodage et à ce moment seulement que les media queries du SVG sont évaluées. Chaîne inchangée, pas de décodage. Pas de décodage, pas d'évaluation.
Vous changez le réglage dans Windows, vous revenez dans l'app, vous cliquez partout... et rien ne bouge. Non pas parce que ça ne marche pas, mais parce que rien n'a été rechargé.
L'attribut data-build est totalement inerte : il n'a aucun effet sur le rendu. Sa seule fonction est de faire varier la chaîne, donc l'URL, donc de forcer un décodage neuf et une nouvelle évaluation des media queries. Et la ligne build : ... affichée en bas de notre banc d'essai n'est pas cosmétique : elle vous dit quelle version vous êtes réellement en train de regarder.
Conséquence directe, et c'est elle qui rend le pattern utilisable en production :
Set(gblBust, GUID())Cette ligne garantit qu'à chaque ouverture de session, la chaîne du data URI est recréée, puis décodée et évaluée en fonction de l'état actuel du système. Ainsi, un utilisateur qui a désactivé les effets d'animation dans Windows retrouve automatiquement ce réglage dans votre app, sans aucune configuration supplémentaire.
C'est la ligne annoncée en section 2 et le halo doit donc porter lui aussi un data-build='" & gblBust & "' sur sa balise <svg>, exactement comme le fait le pattern à trois modes de la section 6.
En production, la valeur est invisible et et sert uniquement à casser le cache : GUID() garantit une valeur unique, sans dépendre de la locale ni du format de date. Lors des tests, l'horodatage était pertinent puisqu'il était affiché.
Un mot sur la portée réelle de cette ligne. Sur un rechargement complet de page, un nouveau document est créé et les images sont décodées à neuf de toute façon. gblBust est là pour couvrir les autres cas, qui sont nombreux dans une application : navigation interne, réaffichage d'un écran, retour depuis un autre onglet. C'est une assurance à une ligne.
9. Le verrou au décodage
Voici le comportement qui surprend tout le monde, moi le premier, et qui a une explication précise.
Ouvrez l'app pour que le banc d'essai affiche no-preference, le carré bouge. Sans fermer l'app, allez dans les réglages Windows, désactivez les effets d'animation, revenez. Le carré bouge toujours.
Ce n'est pas que la media query a échoué mais c'est que le tampon d'image n'a pas été redessiné. Chromium a un bug référencé pour ça, l'issue 1093736 : les images SVG ne sont pas redessinées lorsque les media queries changent. Le rendu fonctionne la plupart du temps, mais ne réagit pas si la préférence bascule en cours de route. Le contournement documenté est exactement celui de la section 8 : forcer le redessin.
// Bouton "Relire la préférence système", OnSelect
Set(gblBust, GUID())Il faut donc être précis sur ce que ce pattern est et n'est pas. Ce n'est pas un flux, c'est un instantané. Le SVG lit la préférence au moment où il est décodé et conserve ce résultat jusqu'au prochain décodage. Rien ne le notifie d'un changement.
Pour les plus curieux ! Il faut même aller plus loin, parce que de tout ceci, peut découler cette question : si le SVG, lui, connaît la préférence, ne peut-on pas la lui faire dire ? Du coup, j'ai essayé encore et encore. Le contrôle Image possède une propriété que le moteur alimente lui-même, OriginalWidth, disponible avec CalculateOriginalDimensions. L'idée était de fabriquer un SVG dont la largeur dépendrait de la media query : la valeur serait devenue un booléen déguisé, lisible depuis Power Fx.
Le canari m'a répondu clairement non. Avec une media query vraie partout, @media (min-width:1px){svg{width:120px}}, la largeur rapportée n'a jamais bougé : 240 quand le document déclare width='240', et 300 quand il ne déclare rien, c'est-à-dire la taille de repli du navigateur. Jamais 120, et le relevé est rigoureusement identique que les effets d'animation de Windows soient activés ou coupés. Le CSS est pourtant bel et bien évalué, la section 7 le prouve ; il ne participe simplement pas au calcul de la taille rapportée à l'hôte. Le moteur renvoie ce que le document déclare, jamais ce qu'il a dessiné.
Un détail de cette tentative mérite d'être gardé, parce qu'il peut piéger au-delà de ce sujet : entre deux décodages, OriginalWidth passe par 0. Une lecture naïve du type largeur < 200 renvoie donc true pendant cet intervalle, alors que l'image n'a pas encore retrouvé ses dimensions. On peut alors prendre cet état transitoire pour une préférence. Pour éviter cela, il faut d'abord vérifier que la largeur est bien supérieure à 0 : largeur > 0 And largeur < 200.
La conclusion est donc plus nette que le simple constat d'instantané : le SVG obéit à la préférence, et l'application ne le saura jamais. Aucune valeur ne franchit la frontière dans ce sens, ni par le contrôle Image, ni par le contrôle Texte HTML, dont la propriété HtmlText se contente de rendre la chaîne que votre propre formule a produite. Pour obtenir un vrai booléen dans Power Fx, il faut un composant de code (PCF) et son window.matchMedia.
Ce point se vérifie également avec la méthode présentée en section 4. La démonstration tient sur un seul écran, dans l'app publiée : deux <img> affichent le même SVG côte à côte, l'un avec gblBust dans sa chaîne et l'autre sans. On désactive les animations dans Windows sans fermer l'app, on déclenche le bust, puis on observe le résultat.
| Voie | Chemin emprunté par le CSS | Canari | Verdict lu |
|---|---|---|---|
| A | balise <style> écrite dans le contrôle Texte HTML |
inactif | rien, la feuille de style a été retirée |
| B | <img> data URI dans le contrôle Texte HTML, avec gblBust |
actif | reduce |
| C | le même <img>, sans gblBust |
actif | no-preference, figé |
| D | contrôle Image, avec gblBust |
actif | reduce |
Le détail déterminant est le canari : il est actif dans les deux cas. L'image figée n'est donc pas en panne. Elle a correctement réagi au moment où elle a été évaluée, et personne ne l'a réévaluée depuis. "Non évalué" et "évalué il y a dix minutes" peuvent avoir le même aspect à l'écran, mais correspondent à deux situations très différentes et n'appellent pas le même correctif. C'est précisément ce que permet de distinguer le troisième état de la section 7.1, et pourquoi il faut le prévoir dès le premier jet.
Reste alors la question essentielle : est-ce grave ?
Pour cette media feature précisément, non. Et la raison est davantage sociologique que technique.
Le mode sombre, les gens peuvent le basculer plusieurs fois par jour, souvent automatiquement au coucher du soleil. prefers-reduced-motion, non. Une personne sensible au mouvement coche la case une fois, à la configuration de son poste, et n'y revient pas. La valeur lue à l'ouverture de la session est la valeur, durablement. La capacité de mise à jour à chaud qui manque au pattern est une capacité dont personne n'a besoin ici.
C'est d'ailleurs exactement le modèle de lecture de tout le reste dans PowerApps. Language(), Host.OSType, User(), tout ce que vous initialisez dans OnStart : ce sont des valeurs lues au démarrage, et personne n'attend qu'elles se propagent à chaud. Personne ne reproche à PowerApps de ne pas réagir instantanément à un changement de langue d'affichage de Windows : on relance l'app. Le pattern n'est pas une exception bancale dans la plateforme, il est cohérent avec elle.
La nuance à garder en tête, et elle est importante si vous étendez la technique : cet argument est solide pour prefers-reduced-motion et pour forced-colors, qui sont des réglages qu'on pose une fois. Je considère qu'il est nettement plus faible pour prefers-color-scheme, que beaucoup d'utilisateurs font basculer automatiquement selon l'heure. Sur celui-là, l'instantané au démarrage vous donnera régulièrement une valeur périmée en cours de journée.
Partie 5 sur 5 · 8 min de lecture
Les détails qui comptent
Ce qui reste, et qui n'entrait dans aucune des quatre premières : les pièges Power Fx du data URI, la chaîne complète du réglage Windows au pixel, l'architecture à trois niveaux qui rend l'ensemble conforme, et les autres préférences système que la même technique va chercher.
10. Les pièges Power Fx du data URI
Quelques détails qui expliquent la forme du code :
EncodeUrl n'est pas optionnel. Un data URI est une URL. Le caractère # y a une signification particulière : il ouvre un fragment. Sans encodage, #d13438 tronque purement et simplement votre SVG au premier code couleur. EncodeUrl le transforme en %23d13438, et fait de même pour <, >, les espaces, etc.
Guillemets simples à l'intérieur du SVG. En Power Fx, une chaîne est délimitée par " et un guillemet littéral s'écrit "". Écrire du XML avec des attributs en "" double la longueur du code et le rend illisible. Le XML accepte parfaitement ' comme délimiteur d'attribut : width='600'. On garde ainsi une formule lisible. Attention toutefois : si vous retouchez le code et que vous doublez une apostrophe par réflexe, le SVG devient invalide et le contrôle affiche une image cassée sans le moindre message d'erreur.
;utf8 plutôt que ;base64. Power Fx n'a pas d'encodeur base64 pratique pour ce cas, et surtout : en clair, la formule reste lisible, facile à comparer et à déboguer. Vous pouvez copier la sortie dans un fichier .svg et l'ouvrir directement.
Les accents. EncodeUrl gère correctement l'UTF-8, mais j'ai pris l'habitude d'écrire mes libellés de test sans accents. Lorsque l'objectif est d'isoler une variable, mieux vaut éviter qu'un éventuel problème d'encodage vienne compliquer le diagnostic. Dans un composant de production, remettez vos accents et testez-les.
La longueur. Power Fx peut gérer des formules très longues, mais une formule inutilement volumineuse reste difficile à maintenir. Le data URI, lui, peut rapidement devenir lourd, surtout s'il est répété pour chaque ligne d'une galerie. Minifiez donc le CSS (pas d'espaces superflus, sélecteurs courts) et évitez de générer un data URI inutilement volumineux par ligne de galerie.
Les chaînes de format sont dans la langue de l'utilisateur. Text() interprète ses jetons à l'exécution, dans la locale de celui qui ouvre l'app, pas dans la vôtre à l'écriture. D'où le préfixe [$-en-US] du banc d'essai : sans lui, un utilisateur néerlandophone ou anglophone ne lit pas les mêmes jetons que vous. En environnement multilingue, c'est loin d'être théorique.
11. La chaîne complète : du réglage Windows au pixel
C'est ce qui rend le résultat réellement utile. Il ne s'agit pas d'un gadget CSS : c'est un signal qui traverse cinq couches.
Réglage d'accessibilité de l'OS
↓
Paramètre système (Windows : SPI_GETCLIENTAREAANIMATION)
↓
Moteur du navigateur / WebView (Edge, Chrome, WebView2, WKWebView)
↓
Media feature CSS : prefers-reduced-motion
↓
Décodage du data URI ← le point de lecture, et le seul
↓
Votre SVG dans un contrôle Image ou Texte HTMLLà où l'utilisateur actionne le levier, selon la plateforme :
| Plateforme | Chemin |
|---|---|
| Windows 11 | Paramètres → Accessibilité → Effets visuels → Effets d'animation (désactiver) |
| Windows 10 | Paramètres → Options d'ergonomie → Affichage → Afficher les animations dans Windows |
| macOS | Réglages Système → Accessibilité → Affichage → Réduire les animations |
| iOS / iPadOS | Réglages → Accessibilité → Mouvement → Réduire les animations |
| Android | Paramètres → Accessibilité → Supprimer les animations |
Attention au piège du réglage voisin : sous Windows, ce n'est ni les effets de transparence, ni les options de performance du panneau système avancé. C'est bien Effets d'animation, dans Accessibilité.
Et pour tester en développement sans toucher à votre poste : dans les DevTools (F12) → menu ⋮ → More tools → Rendering → Emulate CSS media feature prefers-reduced-motion. Vous basculez la valeur, vous relancez votre gblBust, vous observez. C'est aussi le moyen le plus rapide de vérifier qu'un problème vient bien du SVG et pas de Windows : sous émulation, l'OS est court-circuité.
Le point important à faire passer dans une réunion avec un métier ou un service accessibilité : l'utilisateur n'a rien à configurer dans votre application. Elle respecte, silencieusement, une préférence qu'il a déjà exprimée ailleurs, une fois pour toutes.
12. La cascade de préférences
Il reste une chose que ce pattern ne peut pas faire, et elle est structurelle : le canal est à sens unique. Le SVG peut voir la préférence, mais il n'a aucun moyen de la renvoyer à Power Fx. Pas de JavaScript, pas de sortie, pas d'événement. Vous n'écrirez jamais If(gblReducedMotion, ...) à partir de ce mécanisme. C'est pour ça que le pattern consiste à faire descendre l'animation dans le SVG plutôt qu'à essayer de faire remonter un booléen.
Seul un composant PCF ouvre ce canal de retour, avec un écouteur matchMedia qui notifie Power Fx en temps réel. C'est la solution complète, et si les code components sont autorisés dans votre environnement, c'est celle qu'il faut viser à terme.
Conséquence immédiate : tout ce que vous animez avec un contrôle Timer et des Set() sur des propriétés X / Y / Height est du Power Fx pur. Le CSS n'a aucune prise dessus. Ces animations continueront à tourner.
Et c'est précisément là que les deux approches se répondent. Regardez ce que chacune voit :
| Ce que ça pilote | Ce que ça ne voit pas | |
|---|---|---|
| Héritage système, via CSS | les animations déclarées dans le SVG | les Timer Power Fx |
| Surcharge applicative, via Power Fx | les Timer Power Fx | les animations dans le SVG |
Les deux couvrent exactement l'angle mort l'une de l'autre. Ce n'est pas une solution de contournement empilée sur une astuce, c'est une répartition propre des responsabilités. D'où l'architecture que je recommande, en trois niveaux qui se surchargent dans cet ordre :
Niveau 0 : le défaut de la plateforme. Animation active. C'est ce que vous avez aujourd'hui, sans rien faire.
Niveau 1 : l'héritage système. Lu à l'ouverture de la session, appliqué silencieusement, sans aucune configuration demandée à l'utilisateur. Il a déjà exprimé sa préférence une fois, au niveau de son système. C'est exactement le modèle que WCAG encourage, et c'est ce qui fait la différence entre une application qui permet d'être accessible et une application qui l'est par défaut.
Niveau 2 : la surcharge applicative. Un réglage explicite dans votre écran de préférences, enregistré et prioritaire sur le niveau 1. C'est lui qui couvre les animations Power Fx, et c'est lui qui satisfait formellement WCAG 2.2.2, dont le critère demande un mécanisme de mise en pause et non le respect d'une media feature. prefers-reduced-motion n'apparaît que dans les techniques suffisantes, pas dans le critère lui-même.
La formulation qui résume l'ensemble, et qui est le vrai résultat de cet article : le niveau 1 sans le niveau 2 est incomplet, le niveau 2 sans le niveau 1 est médiocre. L'un donne le bon défaut sans rien demander, l'autre donne le contrôle et la conformité. Ensemble, ils font nettement mieux que chacun séparément.
Deux réserves pour finir, qui n'entament pas l'architecture mais qu'il faut poser :
Le rendu dépend de l'hôte. Voir la section 7.5. Testez sur les plateformes de vos utilisateurs réels avant de vous appuyer dessus pour une fonctionnalité importante.
Ce n'est pas un mécanisme documenté par Microsoft. C'est une propriété émergente de l'empilement (Canvas App → navigateur → image SVG → CSS). Ça ne fait pas partie du contrat de la plateforme. C'est d'ailleurs une raison de plus de maintenir le niveau 2 : lui est entièrement sous votre contrôle. Le niveau 1 améliore l'expérience, le niveau 2 la garantit.
13. La suite : les autres media features
Le banc d'essai est extensible tel quel. Il suffit d'ajouter des sondes sur le même modèle "état 0 = non évalué" :
-
forced-colors: active: le mode Contraste élevé de Windows. Probablement la plus utile des quatre en contexte entreprise : quand il est actif, vos couleurs codées en dur dans un SVG sont ignorées ou remplacées, et vos icônes peuvent devenir illisibles. Une sonde dédiée vaut son pesant d'or. Et commeprefers-reduced-motion, c'est un réglage qu'on pose une fois : l'instantané au démarrage suffit. -
prefers-contrast: more | less: la demande d'un contraste renforcé, indépendamment du mode couleur. -
prefers-color-scheme: dark: pour aligner vos illustrations SVG sur le thème du système plutôt que sur un thème applicatif figé. Celle-ci est le mauvais élève du lot : elle bascule en cours de session, et vous retrouverez ici le problème décrit en section 9. Prévoyez un déclencheur de relecture, ou tenez-vous-en au thème applicatif.
Chacune peut, une fois validée, être utilisée exactement comme prefers-reduced-motion : directement dans le SVG, sans un seul If() en Power Fx.
Ce qu'il faut retenir
-
Vos SVG animés tournent sans jamais demander l'avis de l'utilisateur. Power Fx ne peut pas lire les préférences d'accessibilité du système. Le CSS embarqué dans un SVG, si.
-
Une media query n'entre dans une Canvas App que par un data URI SVG. Ni un attribut
style=en ligne ni la balise<style>du contrôle Texte HTML ne peuvent en porter une : l'assainisseur retire la seconde, et une at-rule ne rentre pas dans le premier. Le data URI, lui, passe aussi bien par le contrôle Image que par un<img>posé dans le contrôle Texte HTML. -
La lecture se fait au décodage, donc à l'ouverture de la session. C'est un instantané, pas un flux. Pour une préférence qu'on règle une fois dans sa vie, c'est suffisant.
-
gblBustn'est pas un truc de debug : c'est la ligne qui déclenche la lecture. -
Réduire n'est pas supprimer. Remplacez le mouvement par un équivalent statique qui porte la même information.
-
En production, inversez la logique : état de base statique, animation en opt-in. Le comportement sûr doit être le comportement par défaut.
-
Héritage système pour le bon défaut, surcharge applicative pour le contrôle et la conformité. Les deux, pas l'un ou l'autre.
-
Le pattern à trois états, dont un état "non évalué" visible par défaut, transforme une image en instrument de mesure avec son propre contrôle négatif, utile bien au-delà de ce sujet.