From a83eedfb4772107a95dc6a209b4be4fdcacb017c Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 24 May 2026 08:49:23 +0200 Subject: [PATCH 1/3] feat: add user-configurable motion preference --- app/assets/stylesheets/reset.css | 12 ++-- .../controllers/motion_controller.js | 69 +++++++++++++++++++ app/views/layouts/_motion_preference.html.erb | 30 ++++++++ app/views/layouts/shared/_head.html.erb | 1 + app/views/users/_theme.html.erb | 26 +++++++ 5 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 app/javascript/controllers/motion_controller.js create mode 100644 app/views/layouts/_motion_preference.html.erb diff --git a/app/assets/stylesheets/reset.css b/app/assets/stylesheets/reset.css index d333a38dfa..0fce47864c 100644 --- a/app/assets/stylesheets/reset.css +++ b/app/assets/stylesheets/reset.css @@ -82,19 +82,17 @@ } /* Remove all animations and transitions for people that prefer not to see them */ - @media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { + html[data-motion="reduce"] { + & *, + & *::before, + & *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } - html { - scroll-behavior: initial; - } + scroll-behavior: initial; } dialog { diff --git a/app/javascript/controllers/motion_controller.js b/app/javascript/controllers/motion_controller.js new file mode 100644 index 0000000000..feca77b852 --- /dev/null +++ b/app/javascript/controllers/motion_controller.js @@ -0,0 +1,69 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["autoButton", "reduceButton", "animateButton"] + + connect() { + this.#updateButtons() + } + + setAuto() { + this.#motion = "auto" + } + + setReduce() { + this.#motion = "reduce" + } + + setAnimate() { + this.#motion = "animate" + } + + get #storedMotion() { + return localStorage.getItem("motion") || "auto" + } + + set #motion(motion) { + localStorage.setItem("motion", motion) + + if (motion === "reduce") { + document.documentElement.dataset.motion = "reduce" + this.#removeViewTransitionMeta() + } else if (motion === "animate") { + document.documentElement.dataset.motion = "animate" + this.#restoreViewTransitionMeta() + } else { + delete document.documentElement.dataset.motion // absent → patch falls through to OS + const reduced = this.#osPreferReducedMotion + document.documentElement.dataset.motion = reduced ? "reduce" : "animate" + reduced ? this.#removeViewTransitionMeta() : this.#restoreViewTransitionMeta() + } + + this.#updateButtons() + } + + get #osPreferReducedMotion() { + return window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches + } + + #removeViewTransitionMeta() { + document.querySelector('meta[name="view-transition"]')?.remove() + } + + #restoreViewTransitionMeta() { + if (!document.querySelector('meta[name="view-transition"]')) { + const meta = document.createElement("meta") + meta.name = "view-transition" + meta.content = "same-origin" + document.head.appendChild(meta) + } + } + + #updateButtons() { + const stored = this.#storedMotion + + if (this.hasAutoButtonTarget) { this.autoButtonTarget.checked = (stored === "auto") } + if (this.hasReduceButtonTarget) { this.reduceButtonTarget.checked = (stored === "reduce") } + if (this.hasAnimateButtonTarget) { this.animateButtonTarget.checked = (stored === "animate") } + } +} diff --git a/app/views/layouts/_motion_preference.html.erb b/app/views/layouts/_motion_preference.html.erb new file mode 100644 index 0000000000..52f18d54c9 --- /dev/null +++ b/app/views/layouts/_motion_preference.html.erb @@ -0,0 +1,30 @@ +<%= javascript_tag nonce: true do %> + function prefersReducedMotion() { + const pref = localStorage.getItem("motion") + if (pref === "reduce") return true + if (pref === "animate") return false + return window.matchMedia("(prefers-reduced-motion: reduce)").matches + } + + const reduced = prefersReducedMotion() + document.documentElement.dataset.motion = reduced ? "reduce" : "animate" + + if (reduced) document.querySelector('meta[name="view-transition"]')?.remove() + + const originalMatchMedia = window.matchMedia.bind(window) + window.matchMedia = function(query) { + if (!query.includes("prefers-reduced-motion")) return originalMatchMedia(query) + + const pref = document.documentElement.dataset.motion + if (pref !== "reduce" && pref !== "animate") return originalMatchMedia(query) + + return new Proxy(originalMatchMedia(query), { + get(target, prop) { + if (prop === "matches") return pref === "reduce" + + const value = target[prop] + return typeof value === "function" ? value.bind(target) : value + } + }) + } +<% end %> diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index 9ef153df2b..d918e9a2e3 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -16,6 +16,7 @@ <% turbo_refreshes_with method: :morph, scroll: :preserve %> <%= render "layouts/theme_preference" %> + <%= render "layouts/motion_preference" %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> diff --git a/app/views/users/_theme.html.erb b/app/views/users/_theme.html.erb index 47300abef5..29d0076742 100644 --- a/app/views/users/_theme.html.erb +++ b/app/views/users/_theme.html.erb @@ -23,3 +23,29 @@ + +
+
+

Motion

+
+ +
+ + + + + +
+
From c2130d8d7e1c42c650c1fde47b649ad08317d510 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 24 May 2026 16:30:06 +0200 Subject: [PATCH 2/3] fix: restore @media fallback for reduced motion without JS Users who have prefers-reduced-motion set at the OS level but are browsing without JavaScript would receive no reduced-motion treatment after the @media block was removed. Restore it with a :not([data-motion="animate"]) guard so JS-driven "Always animate" mode still overrides it while keeping the no-JS safety net. --- app/assets/stylesheets/reset.css | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/assets/stylesheets/reset.css b/app/assets/stylesheets/reset.css index 0fce47864c..2442e1938b 100644 --- a/app/assets/stylesheets/reset.css +++ b/app/assets/stylesheets/reset.css @@ -82,6 +82,21 @@ } /* Remove all animations and transitions for people that prefer not to see them */ + @media (prefers-reduced-motion: reduce) { + :root:not([data-motion="animate"]) { + & *, + & *::before, + & *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + scroll-behavior: initial; + } + } + html[data-motion="reduce"] { & *, & *::before, From 88902d1edadef98227eb91764b70e60f5d8f5e30 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 24 May 2026 16:30:26 +0200 Subject: [PATCH 3/3] docs: clarify transient data-motion deletion in auto mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment "absent → patch falls through to OS" was misleading because the attribute is immediately re-set on the next line. Clarify that the deletion is intentionally transient: it temporarily removes the attribute so the matchMedia patch reads the real OS value before resolving to the binary reduce/animate state. --- app/javascript/controllers/motion_controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/controllers/motion_controller.js b/app/javascript/controllers/motion_controller.js index feca77b852..1c4e8683e1 100644 --- a/app/javascript/controllers/motion_controller.js +++ b/app/javascript/controllers/motion_controller.js @@ -33,7 +33,7 @@ export default class extends Controller { document.documentElement.dataset.motion = "animate" this.#restoreViewTransitionMeta() } else { - delete document.documentElement.dataset.motion // absent → patch falls through to OS + delete document.documentElement.dataset.motion // transiently absent so the matchMedia patch reads the real OS value const reduced = this.#osPreferReducedMotion document.documentElement.dataset.motion = reduced ? "reduce" : "animate" reduced ? this.#removeViewTransitionMeta() : this.#restoreViewTransitionMeta()