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
29 changes: 29 additions & 0 deletions apps/storybook/stories/ReducedMotion.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/react';
import { TypingIndicator } from '@/components/cloud-agent-next/TypingIndicator';
import HeaderLogo from '@/components/HeaderLogo';

const meta: Meta = {
title: 'Accessibility/ReducedMotion',
parameters: {
layout: 'centered',
},
};

export default meta;
type Story = StoryObj;

export const Normal: Story = {
render: () => (
<div className="flex flex-col gap-4">
<TypingIndicator />
<HeaderLogo />
</div>
),
};

export const ReducedMotion: Story = {
parameters: {
chromatic: { pauseAnimationAtEnd: true },
},
render: Normal.render,
};
122 changes: 66 additions & 56 deletions apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { useMemo, useState } from 'react';
import { motion } from 'motion/react';
import { motion, useReducedMotion } from 'motion/react';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox';
Expand All @@ -18,58 +18,64 @@ import type { ModelPreset, PresetConfig } from './onboarding.domain';

/** Animated gradient blobs that create an ethereal neon glow inside the frontier card. */
function FrontierGlow() {
const shouldReduce = useReducedMotion();
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-lg">
{/* Base ambient glow */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,oklch(85%_0.18_160_/_0.08),transparent_70%)]" />

{/* Drifting blob 1 — teal/cyan */}
<motion.div
className="absolute h-32 w-32 rounded-full bg-[radial-gradient(circle,oklch(80%_0.2_180_/_0.25),transparent_70%)] blur-xl"
animate={{
x: ['-20%', '60%', '10%', '-20%'],
y: ['10%', '-30%', '50%', '10%'],
scale: [1, 1.3, 0.9, 1],
}}
transition={{ duration: 8, repeat: Infinity, ease: 'easeInOut' }}
style={{ top: '10%', left: '5%' }}
/>

{/* Drifting blob 2 — violet/purple */}
<motion.div
className="absolute h-28 w-28 rounded-full bg-[radial-gradient(circle,oklch(75%_0.2_300_/_0.2),transparent_70%)] blur-xl"
animate={{
x: ['50%', '-10%', '40%', '50%'],
y: ['-20%', '40%', '-10%', '-20%'],
scale: [1.1, 0.8, 1.2, 1.1],
}}
transition={{ duration: 10, repeat: Infinity, ease: 'easeInOut' }}
style={{ top: '20%', right: '10%' }}
/>

{/* Drifting blob 3 — green/lime */}
<motion.div
className="absolute h-24 w-24 rounded-full bg-[radial-gradient(circle,oklch(90%_0.18_130_/_0.18),transparent_70%)] blur-xl"
animate={{
x: ['30%', '-30%', '50%', '30%'],
y: ['60%', '10%', '-20%', '60%'],
scale: [0.9, 1.2, 1, 0.9],
}}
transition={{ duration: 7, repeat: Infinity, ease: 'easeInOut' }}
style={{ bottom: '5%', left: '20%' }}
/>

{/* Drifting blob 4 — pink/magenta */}
<motion.div
className="absolute h-20 w-20 rounded-full bg-[radial-gradient(circle,oklch(80%_0.2_350_/_0.15),transparent_70%)] blur-lg"
animate={{
x: ['-10%', '70%', '20%', '-10%'],
y: ['30%', '-20%', '60%', '30%'],
scale: [1, 1.4, 0.7, 1],
}}
transition={{ duration: 9, repeat: Infinity, ease: 'easeInOut' }}
style={{ top: '40%', left: '40%' }}
/>
{/* Drifting blobs — decorative, skip under reduced motion */}
{!shouldReduce && (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be better to use the automatic method for respecting user motion preferences?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a hybrid approach works best here.

We can use <MotionConfig reducedMotion="user"> at the root level to reduce boilerplate and automatically respect the user preference for motion across standard Motion components. Motion will disable transform and layout animations for reduced motion users while other supported animations can still run.

However useReducedMotion is still useful for components where we explicitly want to change the rendering logic. For example in FrontierGlow (OnboardingStepModel.tsx) the drifting blobs are purely decorative so using !shouldReduce lets us avoid rendering them entirely instead of leaving them as static background elements.

Proposed solution:

Add <MotionConfig reducedMotion="user"> around the root providers so standard Motion components respect reduced motion automatically

Keep useReducedMotion only in components where reduced motion requires conditional rendering or other custom logic such as FrontierGlow

<>
{/* Drifting blob 1 — teal/cyan */}
<motion.div
className="absolute h-32 w-32 rounded-full bg-[radial-gradient(circle,oklch(80%_0.2_180_/_0.25),transparent_70%)] blur-xl"
animate={{
x: ['-20%', '60%', '10%', '-20%'],
y: ['10%', '-30%', '50%', '10%'],
scale: [1, 1.3, 0.9, 1],
}}
transition={{ duration: 8, repeat: Infinity, ease: 'easeInOut' }}
style={{ top: '10%', left: '5%' }}
/>

{/* Drifting blob 2 — violet/purple */}
<motion.div
className="absolute h-28 w-28 rounded-full bg-[radial-gradient(circle,oklch(75%_0.2_300_/_0.2),transparent_70%)] blur-xl"
animate={{
x: ['50%', '-10%', '40%', '50%'],
y: ['-20%', '40%', '-10%', '-20%'],
scale: [1.1, 0.8, 1.2, 1.1],
}}
transition={{ duration: 10, repeat: Infinity, ease: 'easeInOut' }}
style={{ top: '20%', right: '10%' }}
/>

{/* Drifting blob 3 — green/lime */}
<motion.div
className="absolute h-24 w-24 rounded-full bg-[radial-gradient(circle,oklch(90%_0.18_130_/_0.18),transparent_70%)] blur-xl"
animate={{
x: ['30%', '-30%', '50%', '30%'],
y: ['60%', '10%', '-20%', '60%'],
scale: [0.9, 1.2, 1, 0.9],
}}
transition={{ duration: 7, repeat: Infinity, ease: 'easeInOut' }}
style={{ bottom: '5%', left: '20%' }}
/>

{/* Drifting blob 4 — pink/magenta */}
<motion.div
className="absolute h-20 w-20 rounded-full bg-[radial-gradient(circle,oklch(80%_0.2_350_/_0.15),transparent_70%)] blur-lg"
animate={{
x: ['-10%', '70%', '20%', '-10%'],
y: ['30%', '-20%', '60%', '30%'],
scale: [1, 1.4, 0.7, 1],
}}
transition={{ duration: 9, repeat: Infinity, ease: 'easeInOut' }}
style={{ top: '40%', left: '40%' }}
/>
</>
)}

{/* Outer glow ring */}
<div className="absolute inset-0 rounded-lg ring-1 ring-inset ring-white/[0.08]" />
Expand All @@ -87,6 +93,7 @@ function PresetCard({
onSelect: () => void;
}) {
const isFrontier = preset.key === 'frontier';
const shouldReduce = useReducedMotion();

return (
<button
Expand All @@ -110,7 +117,7 @@ function PresetCard({
<motion.div
layoutId="preset-ring"
className="pointer-events-none absolute inset-0 rounded-lg ring-1 ring-[color:oklch(95%_0.15_108_/_0.5)]"
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
transition={shouldReduce ? { duration: 0 } : { type: 'spring', stiffness: 400, damping: 30 }}
/>
)}

Expand All @@ -136,6 +143,7 @@ function PresetCard({
}

function CustomCard({ isSelected, onSelect }: { isSelected: boolean; onSelect: () => void }) {
const shouldReduce = useReducedMotion();
return (
<button
type="button"
Expand All @@ -152,7 +160,7 @@ function CustomCard({ isSelected, onSelect }: { isSelected: boolean; onSelect: (
<motion.div
layoutId="preset-ring"
className="pointer-events-none absolute inset-0 rounded-lg ring-1 ring-[color:oklch(95%_0.15_108_/_0.5)]"
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
transition={shouldReduce ? { duration: 0 } : { type: 'spring', stiffness: 400, damping: 30 }}
/>
)}

Expand Down Expand Up @@ -187,11 +195,12 @@ function ModelRolePickers({
isLoadingModels: boolean;
modelsError: string | undefined;
}) {
const shouldReduce = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{ duration: 0.2 }}
initial={shouldReduce ? {} : { opacity: 0, height: 0 }}
animate={shouldReduce ? {} : { opacity: 1, height: 'auto' }}
transition={shouldReduce ? { duration: 0 } : { duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-4 space-y-3 rounded-lg border border-white/[0.06] bg-white/[0.02] p-4">
Expand Down Expand Up @@ -245,6 +254,7 @@ function CustomModelPicker({
modelOptions: ModelOption[];
isLoadingModels: boolean;
}) {
const shouldReduce = useReducedMotion();
const roleRows: [string, string, (v: string) => void][] = [
['Mayor', customMayor, setCustomMayor],
['Refinery', customRefinery, setCustomRefinery],
Expand All @@ -253,9 +263,9 @@ function CustomModelPicker({

return (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{ duration: 0.2 }}
initial={shouldReduce ? {} : { opacity: 0, height: 0 }}
animate={shouldReduce ? {} : { opacity: 1, height: 'auto' }}
transition={shouldReduce ? { duration: 0 } : { duration: 0.2 }}
className="mt-4 overflow-hidden"
>
<div className="space-y-3 rounded-lg border border-white/[0.06] bg-white/[0.02] p-4">
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,15 @@
--ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
}

@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}

/*
Tailwind CSS v4 defaults border color to `currentcolor`. Keep implicit
borders on Kilo's semantic border token so legacy bare `border` utilities
Expand Down Expand Up @@ -437,6 +446,14 @@
animation: pulse-glow 0.6s ease-out;
}

@media (prefers-reduced-motion: reduce) {
.animate-pulse-once {
animation: none;
transform: scale(1);
box-shadow: none;
}
}

@keyframes usage-pending-bar {
0%,
12% {
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/components/HeaderLogo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import Link from 'next/link';
import { cn } from '@/lib/utils';
import { AnimatePresence, motion } from 'motion/react';
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
import { usePathname } from 'next/navigation';
import { useState } from 'react';

Expand All @@ -18,6 +18,8 @@ export default function HeaderLogo({ className, href }: HeaderLogoProps) {
const pathname = usePathname();
const [isHovered, setIsHovered] = useState(false);

const shouldReduceMotion = useReducedMotion();

const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (pathname === href) {
e.preventDefault();
Expand All @@ -30,8 +32,8 @@ export default function HeaderLogo({ className, href }: HeaderLogoProps) {
<motion.div
className="relative flex size-12 flex-none items-center justify-center overflow-hidden font-bold"
aria-hidden="true"
animate={{ rotate: isHovered ? 90 : 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
animate={shouldReduceMotion ? {} : { rotate: isHovered ? 90 : 0 }}
transition={shouldReduceMotion ? {} : { duration: 0.3, ease: 'easeInOut' }}
style={{ transformStyle: 'preserve-3d' }}
>
<AnimatePresence>
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/cloud-agent-next/TypingIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ export function TypingIndicator() {
<Bot className="h-4 w-4" />
</div>
<div className="mt-2 flex gap-1">
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400 [animation-delay:-0.3s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400 [animation-delay:-0.15s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400" />
<div className="motion-reduce:animate-none h-2 w-2 animate-bounce rounded-full bg-gray-400 [animation-delay:-0.3s]" />
<div className="motion-reduce:animate-none h-2 w-2 animate-bounce rounded-full bg-gray-400 [animation-delay:-0.15s]" />
<div className="motion-reduce:animate-none h-2 w-2 animate-bounce rounded-full bg-gray-400" />
</div>
</div>
);
Expand Down
32 changes: 17 additions & 15 deletions apps/web/src/components/gastown/BeadBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import { Trash2, Clock, Play } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { motion, AnimatePresence } from 'motion/react';
import { motion, AnimatePresence, useReducedMotion } from 'motion/react';

type Bead = {
bead_id: string;
Expand Down Expand Up @@ -203,6 +203,8 @@ export function BeadBoard({
selectedBeadId,
agentNameById,
}: BeadBoardProps) {
const shouldReduce = useReducedMotion();

if (isLoading) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
Expand All @@ -226,9 +228,9 @@ export function BeadBoard({
return (
<motion.div
key={status}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: colIdx * 0.08, duration: 0.3 }}
initial={shouldReduce ? {} : { opacity: 0, y: 12 }}
animate={shouldReduce ? {} : { opacity: 1, y: 0 }}
transition={shouldReduce ? { duration: 0 } : { delay: colIdx * 0.08, duration: 0.3 }}
>
<div className="mb-3 flex items-center gap-2">
<span
Expand All @@ -241,21 +243,21 @@ export function BeadBoard({
</span>
<motion.span
key={columnBeads.length}
initial={{ scale: 1.3, opacity: 0.5 }}
animate={{ scale: 1, opacity: 1 }}
initial={shouldReduce ? {} : { scale: 1.3, opacity: 0.5 }}
animate={shouldReduce ? {} : { scale: 1, opacity: 1 }}
className="text-xs text-white/45"
>
{columnBeads.length}
</motion.span>
</div>
<div className="space-y-2">
<AnimatePresence mode="popLayout" initial={false}>
<AnimatePresence mode={shouldReduce ? 'sync' : 'popLayout'} initial={false}>
{columnBeads.length === 0 && (
<motion.p
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={shouldReduce ? {} : { opacity: 0 }}
animate={shouldReduce ? {} : { opacity: 1 }}
exit={shouldReduce ? {} : { opacity: 0 }}
className="py-4 text-center text-xs text-white/35"
>
No beads
Expand All @@ -264,11 +266,11 @@ export function BeadBoard({
{columnBeads.map(bead => (
<motion.div
key={bead.bead_id}
layout
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -10 }}
transition={{
layout={!shouldReduce}
initial={shouldReduce ? {} : { opacity: 0, scale: 0.95, y: 10 }}
animate={shouldReduce ? {} : { opacity: 1, scale: 1, y: 0 }}
exit={shouldReduce ? {} : { opacity: 0, scale: 0.95, y: -10 }}
transition={shouldReduce ? { duration: 0 } : {
layout: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] },
opacity: { duration: 0.2 },
scale: { duration: 0.2 },
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ function Sidebar({
<div
data-slot="sidebar-gap"
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'relative w-(--sidebar-width) bg-transparent motion-reduce:transition-none transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
Expand Down