TL;DR In a fresh shadcn project, Dialog and Accordion kept animating with Reduce Motion on, because tw-animate-css has no reduced-motion rule. A data-slot CSS rule turns them off without breaking the focus trap. Gate Framer Motion hover props with useReducedMotion, and draw focus with outline when you animate box-shadow.
To add motion to shadcn/ui without breaking accessibility, check two things on every animated component: it stops moving when the visitor has asked for reduced motion, and keyboard focus stays visible while it moves. In a fresh shadcn project both can fail without any error. The stock Dialog and Accordion kept animating under Reduce Motion, MotionConfig reducedMotion="user" didn't stop a hover scale, and a hover glow wiped out the Button's focus ring after one hover. Each fix below is a few lines.
I build animated components for a living (qodesign, React and TypeScript with Tailwind, most also use Framer Motion), and these are the checks I run before anything ships. Everything here was tested in a fresh Next.js 16.4 app set up with npx shadcn@latest init (shadcn 4.22.1, the radix-nova style, tw-animate-css 1.4.0) plus framer-motion 14.4.0. It passed strict tsc and next build. Then I drove the production build in headless Chromium twice, once with reducedMotion: 'reduce' and once without, and read computed styles at fixed moments.
If you want the general background on the media query, the Tailwind variants and a safe useReducedMotion hook, that's in prefers-reduced-motion in React. This post is about the parts specific to shadcn.
animate-in and animate-out come from tw-animate-css, which the shadcn init imports into globals.css. I searched the installed package's CSS for prefers-reduced-motion and found nothing. shadcn's own shadcn/tailwind.css has two reduced-motion blocks, and both cover its shimmer effect only.
What the browser reported with Reduce Motion on:
Element
Animation
Duration
Dialog content, opening
enter
0.1s
Dialog overlay, opening
enter
0.1s
Dialog content, closing
exit
0.1s
Accordion content, opening
accordion-down
0.2s
Those values are identical to the run without Reduce Motion. A 100 ms fade and zoom is small, but WCAG 2.3.3 asks that motion triggered by an interaction can be turned off. The reduced-motion setting is the switch people already have.
Stack Cart Drawer running live. Products fly into the cart as discs; open it and they unfold into line items.
/* stop tw-animate-css enter/exit and accordion keyframes for people who asked for less motion */@media (prefers-reduced-motion: reduce) { [data-slot="dialog-overlay"], [data-slot="dialog-content"], [data-slot="sheet-overlay"], [data-slot="sheet-content"], [data-slot="dropdown-menu-content"], [data-slot="popover-content"], [data-slot="tooltip-content"], [data-slot="accordion-content"] { animation: none !important; }}
It keys on the data-slot attributes the shadcn components render, so you don't have to edit each component file. I checked the slot names against the Dialog, Sheet, Dropdown Menu, Popover, Tooltip and Accordion files the CLI installed, and measured the effect on Dialog and Accordion. Open your components/ui/dialog.tsx first and check that your copies render data-slot. If your project's files predate that attribute, the selectors won't match.
After the change, with Reduce Motion on:
Dialog content and overlay: animation none, duration 0s.
Closing: the dialog was gone at once, with no exit animation.
Accordion: animation none, and the open panel was still 30 px tall with its text.
Without Reduce Motion, all the original animations still ran.
In the first run, 20 ms after Escape the closing dialog was still in the page, running its exit animation. Radix keeps it mounted until that ends, so I wanted to see what removing the animation does to focus. I opened the dialog from the keyboard (focus the trigger, press Enter), pressed Tab four times and then Escape. Focus moved into the dialog on open (to the Cancel button), stayed inside after four Tabs, and went back to the trigger on close. The results were the same with and without Reduce Motion, before and after the fix. Radix documents the focus trap on its Dialog page. If your dialog leaves pointer-events: none stuck on the body after closing, that's a different bug, covered in the Radix dialog pointer-events fix.
Motion's docs say that with reducedMotion="user" "transform and layout animations will be disabled". In my test that held for animate: a box sliding in with initial={{ x: -40 }} animate={{ x: 0 }} sat at x: -31 after 150 ms normally, and had no transform at all after 150 ms with Reduce Motion on.
The hover was different. With Reduce Motion on, 40 ms after the pointer arrived the button was at scale 1.007, and it settled at 1.04, the same path as without the setting. So MotionConfig alone didn't stop gesture animations on this setup.
With Reduce Motion on, that button had no transform at 40 ms or after 500 ms. Without it, the lift ran as before. I keep MotionConfig reducedMotion="user" at the root as well, because it covers the animate cases for free.
shadcn's Button draws its focus ring as a box-shadow (focus-visible:ring-3 focus-visible:ring-ring/50). On the lift button above, keyboard focus still showed the 3 px ring, since a scale only touches transform.
After one hover, Motion leaves an inline style on the button, box-shadow: rgba(139, 108, 255, 0) 0px 0px 0px. Inline styles beat classes, so when I tabbed to it afterwards the computed box-shadow was that transparent value and the ring never showed. :focus-visible matched and nothing appeared. That fails WCAG 2.4.7 Focus Visible, and no tool will warn you, because the class is still there.
The fix is to draw focus with outline, which the animation doesn't touch:
After the same hover-then-Tab sequence, this one showed a solid 2 px outline. The outline-solid matters: shadcn's base button class includes outline-none, and in Tailwind v4 outline-2 alone kept the style at none in my first attempt.
I hit the same thing on one of my own free components while testing a different post this week: a toggle with an inline inset shadow hid its own ring, since fixed. Any component that sets box-shadow inline, from Motion or from a style prop, needs this check.
One more detail: shadcn's Button has transition-all, so the ring fades in over about 150 ms. If you check focus styles in a test, wait a few hundred milliseconds after focusing or you'll read a transparent ring and think it's broken.
Add the data-slot reduced-motion rule to globals.css once.
Wrap the app in MotionConfig reducedMotion="user", and gate whileHover/whileTap transforms with useReducedMotion.
After adding motion, open a dialog from the keyboard: focus goes in, stays in, returns to the trigger.
Hover the component, move away, then Tab to it. If you animate box-shadow, use an outline for focus.
Test with the OS setting or Chromium's emulation (DevTools Rendering panel, or reducedMotion: 'reduce' in Playwright), and read computed styles. Watching the screen is too easy to fool.
In a fresh shadcn 4.22 project with tw-animate-css 1.4.0, no. Dialog, Accordion and the other enter/exit animations ran at full length with Reduce Motion on. A short @media (prefers-reduced-motion: reduce) rule that targets the components' data-slot attributes and sets animation: none turns them off.
In my test with framer-motion 14.4.0 it stopped animate transforms but not a whileHover scale. Gate gesture props with the useReducedMotion hook, for example whileHover={reduce ? undefined : { scale: 1.04 }}.
The ring is a box-shadow. If the hover animates boxShadow, Motion leaves an inline box-shadow behind, which overrides the ring class. Draw focus with focus-visible:outline-solid focus-visible:outline-2 instead, or animate a different property.
Not in my test. With the animations removed, focus still moved into the dialog on open, stayed inside while tabbing, and returned to the trigger on Escape.