fix(ui): honor reduced motion across timeline motion

Make spring-driven layout changes snap when reduced motion is enabled and disable the remaining timeline and explore animations that were still easing despite the system preference.
This commit is contained in:
Kit Langton
2026-03-05 11:01:00 -05:00
parent 39d26ffed7
commit 922e2d2f1b
4 changed files with 80 additions and 12 deletions

View File

@@ -1,5 +1,6 @@
import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
export interface GrowBoxProps {
children: JSX.Element
@@ -48,6 +49,7 @@ export interface GrowBoxProps {
* Used for timeline turns, assistant part groups, and user messages.
*/
export function GrowBox(props: GrowBoxProps) {
const reduce = prefersReducedMotion
const spring = () => props.spring ?? GROW_SPRING
const toggleSpring = () => props.toggleSpring ?? spring()
let mode: "mount" | "toggle" = "mount"
@@ -83,7 +85,8 @@ export function GrowBox(props: GrowBoxProps) {
const edgeIdle = () => Math.max(0, props.edgeIdle ?? 320)
const edgeFade = () => Math.max(0.05, props.edgeFade ?? 0.24)
const edgeRise = () => Math.max(0.05, props.edgeRise ?? 0.2)
const edgeReady = () => props.animate !== false && open() && edge() && edgeHeight() > 0
const animated = () => props.animate !== false && !reduce()
const edgeReady = () => animated() && open() && edge() && edgeHeight() > 0
const stopEdgeTimer = () => {
if (edgeTimer === undefined) return
@@ -99,7 +102,7 @@ export function GrowBox(props: GrowBoxProps) {
}
edgeAnim?.stop()
edgeAnim = undefined
if (instant) {
if (instant || reduce()) {
edgeRef.style.opacity = "0"
edgeOn = false
return
@@ -124,6 +127,11 @@ export function GrowBox(props: GrowBoxProps) {
const showEdge = () => {
stopEdgeTimer()
if (!edgeRef) return
if (reduce()) {
edgeRef.style.opacity = `${edgeOpacity()}`
edgeOn = true
return
}
if (edgeOn && edgeAnim === undefined) {
edgeRef.style.opacity = `${edgeOpacity()}`
return
@@ -175,6 +183,10 @@ export function GrowBox(props: GrowBoxProps) {
const fadeBodyIn = (nextMode: "mount" | "toggle" = "mount") => {
if (props.fade === false || !body) return
if (reduce()) {
clearBody()
return
}
hideBody()
fadeAnim?.stop()
fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, nextMode === "toggle" ? toggleSpring() : spring())
@@ -185,6 +197,9 @@ export function GrowBox(props: GrowBoxProps) {
}
const setInstant = (visible: boolean) => {
const next = visible ? targetHeight() : 0
springTarget = next
height.jump(next)
root!.style.height = visible ? "" : "0px"
root!.style.overflow = visible ? "" : "clip"
hideEdge(true)
@@ -207,6 +222,18 @@ export function GrowBox(props: GrowBoxProps) {
const setHeight = (nextMode: "mount" | "toggle" = "mount") => {
if (!root || !open()) return
const next = targetHeight()
if (reduce()) {
springTarget = next
height.jump(next)
if (props.autoHeight === false || watch()) {
root.style.height = `${next}px`
root.style.overflow = next > 0 ? "visible" : "clip"
return
}
root.style.height = "auto"
root.style.overflow = next > 0 ? "visible" : "clip"
return
}
if (next === springTarget) return
const prev = currentHeight()
if (Math.abs(next - prev) < 1) {
@@ -266,7 +293,7 @@ export function GrowBox(props: GrowBoxProps) {
offChange()
})
if (!props.animate) {
if (!animated()) {
setInstant(open())
return
}
@@ -310,7 +337,7 @@ export function GrowBox(props: GrowBoxProps) {
(value) => {
if (value === undefined) return
if (!root || !body) return
if (!animateToggle()) {
if (!animateToggle() || reduce()) {
setInstant(value)
return
}
@@ -342,7 +369,7 @@ export function GrowBox(props: GrowBoxProps) {
createEffect(() => {
if (!edgeRef) return
edgeRef.style.height = `${edgeHeight()}px`
if (props.animate === false || !open() || edgeHeight() <= 0) {
if (!animated() || !open() || edgeHeight() <= 0) {
hideEdge(true)
return
}
@@ -350,6 +377,14 @@ export function GrowBox(props: GrowBoxProps) {
hideEdge()
})
createEffect(() => {
if (!root || !body) return
if (!reduce()) return
fadeAnim?.stop()
edgeAnim?.stop()
setInstant(open())
})
onCleanup(() => {
stopEdgeTimer()
if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)

View File

@@ -895,11 +895,12 @@ function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean
const wiped = new Set<string>()
const [mounted, setMounted] = createSignal(false)
onMount(() => setMounted(true))
const reduce = prefersReducedMotion
const show = () => mounted() && props.pending
const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING)
const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING)
return (
<div style={{ opacity: opacity(), filter: `blur(${blur()}px)` }}>
<div style={{ opacity: reduce() ? (show() ? 1 : 0) : opacity(), filter: `blur(${reduce() ? 0 : blur()}px)` }}>
<RollingResults
items={props.parts}
rows={3}
@@ -922,6 +923,7 @@ function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean
if (!el || !d) return
if (wiped.has(k)) return
wiped.add(k)
if (reduce()) return
el.style.maskImage = WIPE_MASK
el.style.webkitMaskImage = WIPE_MASK
el.style.maskSize = "240% 100%"

View File

@@ -1,6 +1,7 @@
import { attachSpring, motionValue } from "motion"
import type { SpringOptions } from "motion"
import { createEffect, createSignal, onCleanup } from "solid-js"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
type Opt = Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">
const eq = (a: Opt | undefined, b: Opt | undefined) =>
@@ -13,24 +14,41 @@ const eq = (a: Opt | undefined, b: Opt | undefined) =>
export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
const read = () => (typeof options === "function" ? options() : options)
const reduce = prefersReducedMotion
const [value, setValue] = createSignal(target())
const source = motionValue(value())
const spring = motionValue(value())
let config = read()
let stop = attachSpring(spring, source, config)
let reduced = reduce()
let stop = reduced ? () => {} : attachSpring(spring, source, config)
let off = spring.on("change", (next) => setValue(next))
createEffect(() => {
source.set(target())
const next = target()
if (reduced) {
source.set(next)
spring.set(next)
setValue(next)
return
}
source.set(next)
})
createEffect(() => {
if (!options) return
const next = read()
if (eq(config, next)) return
const skip = reduce()
if (eq(config, next) && reduced === skip) return
config = next
reduced = skip
stop()
stop = attachSpring(spring, source, next)
stop = skip ? () => {} : attachSpring(spring, source, next)
if (skip) {
const value = target()
source.set(value)
spring.set(value)
setValue(value)
return
}
setValue(spring.get())
})