Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions app/assets/stylesheets/reset.css
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,31 @@

/* Remove all animations and transitions for people that prefer not to see them */
Comment thread
xseman marked this conversation as resolved.
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
: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,
& *::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;
}
Comment thread
xseman marked this conversation as resolved.

dialog {
Expand Down
69 changes: 69 additions & 0 deletions app/javascript/controllers/motion_controller.js
Original file line number Diff line number Diff line change
@@ -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 // 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()
}

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") }
}
}
30 changes: 30 additions & 0 deletions app/views/layouts/_motion_preference.html.erb
Original file line number Diff line number Diff line change
@@ -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
}
})
}
Comment thread
xseman marked this conversation as resolved.
<% end %>
1 change: 1 addition & 0 deletions app/views/layouts/shared/_head.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>

Expand Down
26 changes: 26 additions & 0 deletions app/views/users/_theme.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,29 @@
</label>
</div>
</section>

<section class="settings__section" data-controller="motion">
Comment thread
xseman marked this conversation as resolved.
<header>
<h2 class="divider">Motion</h2>
</header>

<div class="theme-switcher flex gap max-width justify-center txt-small margin-block-start-half">
<label class="btn theme-switcher__btn">
<%= icon_tag "monitor" %>
<span class="overflow-ellipsis">Same as OS</span>
<input type="radio" name="motion" data-action="click->motion#setAuto" data-motion-target="autoButton">
</label>

<label class="btn theme-switcher__btn">
<%= icon_tag "minus" %>
<span class="overflow-ellipsis">Reduce motion</span>
<input type="radio" name="motion" data-action="click->motion#setReduce" data-motion-target="reduceButton">
</label>

<label class="btn theme-switcher__btn">
<%= icon_tag "bolt" %>
<span class="overflow-ellipsis">Always animate</span>
<input type="radio" name="motion" data-action="click->motion#setAnimate" data-motion-target="animateButton">
</label>
Comment thread
xseman marked this conversation as resolved.
</div>
</section>