Add user-controlled animation preferences and reduce motion support

- Persist prefersReducedMotion and animationSpeedMultiplier in user
profile - Provide UI controls to toggle reduce motion and adjust
animation speed globally - Centralize animation preferences via provider
and useAnimationPreferences hook - Apply preferences to charts’
animations (duration, enabled/disabled) - Inline script in layout to
apply preferences early and avoid FOUC - Update CSS to respect user
preference with reduced motion overrides and variable animation speeds
This commit is contained in:
2025-08-11 17:54:53 -04:00
parent 46767ca7e2
commit a270f6c1e5
10 changed files with 1150 additions and 14 deletions
@@ -1,6 +1,7 @@
"use client";
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
@@ -51,6 +52,12 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
paid: "hsl(142, 76%, 36%)", // green
overdue: "hsl(0, 84%, 60%)", // red
};
// Animation / motion preferences
const { prefersReducedMotion, animationSpeedMultiplier } =
useAnimationPreferences();
const pieAnimationDuration = Math.round(
600 / (animationSpeedMultiplier || 1),
);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("en-US", {
@@ -113,6 +120,9 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
outerRadius={80}
stroke="none"
dataKey="count"
isAnimationActive={!prefersReducedMotion}
animationDuration={pieAnimationDuration}
animationEasing="ease-out"
>
{chartData.map((entry, index) => (
<Cell
@@ -10,6 +10,7 @@ import {
} from "recharts";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
interface Invoice {
id: string;
@@ -87,6 +88,13 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
}),
}));
// Animation / motion preferences
const { prefersReducedMotion, animationSpeedMultiplier } =
useAnimationPreferences();
const barAnimationDuration = Math.round(
500 / (animationSpeedMultiplier || 1),
);
const CustomTooltip = ({
active,
payload,
@@ -167,24 +175,36 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
stackId="a"
fill="hsl(0, 0%, 60%)"
radius={[0, 0, 0, 0]}
isAnimationActive={!prefersReducedMotion}
animationDuration={barAnimationDuration}
animationEasing="ease-out"
/>
<Bar
dataKey="paidInvoices"
stackId="a"
fill="var(--chart-2)"
radius={[0, 0, 0, 0]}
isAnimationActive={!prefersReducedMotion}
animationDuration={barAnimationDuration}
animationEasing="ease-out"
/>
<Bar
dataKey="pendingInvoices"
stackId="a"
fill="var(--chart-1)"
radius={[0, 0, 0, 0]}
isAnimationActive={!prefersReducedMotion}
animationDuration={barAnimationDuration}
animationEasing="ease-out"
/>
<Bar
dataKey="overdueInvoices"
stackId="a"
fill="var(--chart-3)"
radius={[2, 2, 0, 0]}
isAnimationActive={!prefersReducedMotion}
animationDuration={barAnimationDuration}
animationEasing="ease-out"
/>
</BarChart>
</ResponsiveContainer>
@@ -10,6 +10,7 @@ import {
} from "recharts";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
interface Invoice {
id: string;
@@ -99,6 +100,8 @@ export function RevenueChart({ invoices }: RevenueChartProps) {
return null;
};
const { prefersReducedMotion, animationSpeedMultiplier } =
useAnimationPreferences();
if (chartData.length === 0) {
return (
<div className="flex h-64 items-center justify-center">
@@ -147,6 +150,11 @@ export function RevenueChart({ invoices }: RevenueChartProps) {
stroke="hsl(0, 0%, 60%)"
strokeWidth={2}
fill="url(#revenueGradient)"
isAnimationActive={!prefersReducedMotion}
animationDuration={Math.round(
600 / (animationSpeedMultiplier ?? 1),
)}
animationEasing="ease-out"
/>
</AreaChart>
</ResponsiveContainer>
@@ -59,6 +59,9 @@ import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react";
import { Switch } from "~/components/ui/switch";
import { Slider } from "~/components/ui/slider";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
export function SettingsContent() {
const { data: session } = useSession();
@@ -76,6 +79,25 @@ export function SettingsContent() {
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
// Animation preferences via provider (centralized)
const {
prefersReducedMotion,
animationSpeedMultiplier,
updatePreferences,
isUpdating: animationPrefsUpdating,
setPrefersReducedMotion,
setAnimationSpeedMultiplier,
} = useAnimationPreferences();
const handleSaveAnimationPreferences = (e: React.FormEvent) => {
e.preventDefault();
updatePreferences({
prefersReducedMotion,
animationSpeedMultiplier,
});
toast.success("Animation preferences updated");
};
// Queries
const { data: profile, refetch: refetchProfile } =
api.settings.getProfile.useQuery();
@@ -271,6 +293,8 @@ export function SettingsContent() {
}
}, [profile?.name, name]);
// (Removed direct DOM mutation; provider handles applying preferences globally)
const dataStatItems = [
{
label: "Clients",
@@ -498,6 +522,99 @@ export function SettingsContent() {
</CardContent>
</Card>
{/* Accessibility & Animation */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Info className="text-primary h-5 w-5" />
Accessibility & Animation
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSaveAnimationPreferences} className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<Label>Reduce Motion</Label>
<p className="text-muted-foreground text-xs leading-snug">
Turn this on to reduce or remove non-essential animations and
transitions.
</p>
</div>
<Switch
checked={prefersReducedMotion}
onCheckedChange={(checked) =>
setPrefersReducedMotion(Boolean(checked))
}
aria-label="Toggle reduced motion"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="mb-0">Animation Speed Multiplier</Label>
<span className="text-muted-foreground text-xs font-medium">
{prefersReducedMotion
? "1.00x (locked)"
: `${animationSpeedMultiplier.toFixed(2)}x`}
</span>
</div>
<p className="text-muted-foreground text-xs leading-snug">
Adjust global animation duration scaling. Lower values (0.25×,
0.5×, 0.75×) slow animations; higher values (2×, 3×, 4×) speed
them up.
</p>
<div className="flex flex-col gap-2">
{/* Slider (desktop / larger screens) */}
<div className="hidden sm:block">
<Slider
value={[animationSpeedMultiplier]}
min={0.25}
max={4}
step={0.25}
ticks={[0.25, 0.5, 0.75, 1, 2, 3, 4]}
formatTick={(t) => (t === 1 ? "1x" : `${t}x`)}
onValueChange={(v: number[]) =>
setAnimationSpeedMultiplier(v[0] ?? 1)
}
aria-label="Animation speed multiplier"
className="mt-1"
disabled={prefersReducedMotion}
/>
</div>
{/* Dropdown fallback (small screens) */}
<div className="block sm:hidden">
<select
className="bg-background border-border text-foreground w-full rounded-md border px-2 py-2 text-sm disabled:opacity-60"
value={animationSpeedMultiplier}
disabled={prefersReducedMotion}
onChange={(e) =>
setAnimationSpeedMultiplier(
parseFloat(e.target.value) || 1,
)
}
aria-label="Animation speed multiplier select"
>
{[0.25, 0.5, 0.75, 1, 2, 3, 4].map((v) => (
<option key={v} value={v}>
{v === 1 ? "1x (default)" : `${v}x`}
</option>
))}
</select>
</div>
</div>
</div>
<Button
type="submit"
disabled={animationPrefsUpdating}
variant="default"
>
{animationPrefsUpdating ? "Saving..." : "Save Preferences"}
</Button>
</form>
</CardContent>
</Card>
{/* Data Management */}
<Card className="bg-card border-border border">
<CardHeader>