# shadcn/ui Animated Components: Add Motion Without Breaking Accessibility

> 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.

- URL: https://qodesign.dev/blog/shadcn-ui-animation-accessibility-reduced-motion-focus
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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](/blog/prefers-reduced-motion-react). This post is about the parts specific to shadcn.

## Do shadcn's built-in animations respect reduced motion?

Not in my test. Here is the class list on the stock `DialogContent` (trimmed):

```tsx
"fixed top-1/2 left-1/2 z-50 grid w-full ... duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
```

`animate-in` and `animate-out` come from [tw-animate-css](https://github.com/Wombosvideo/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](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html) asks that motion triggered by an interaction can be turned off. The reduced-motion setting is the switch people already have.

## Turn the enter and exit animations off

I added this to the end of `app/globals.css`:

```css
/* 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.

### Does turning animations off break the focus trap?

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](https://www.radix-ui.com/primitives/docs/components/dialog). 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](/blog/radix-dialog-pointer-events-none-body-fix).

## Adding Framer Motion to shadcn's Button

`motion.create` wraps the shadcn Button and keeps its variants, `asChild` and focus styles:

```tsx
"use client"

import { motion } from "framer-motion"
import { Button } from "@/components/ui/button"

// motion.create keeps shadcn's Button (variants, focus ring, asChild) and adds motion props
export const MotionButton = motion.create(Button)
```

```tsx
<MotionConfig reducedMotion="user">
  <MotionButton
    whileHover={{ scale: 1.04 }}
    whileTap={{ scale: 0.97 }}
    transition={{ type: "spring", stiffness: 400, damping: 25 }}
  >
    Start free trial
  </MotionButton>
</MotionConfig>
```

Motion's docs say that with [`reducedMotion="user"`](https://motion.dev/docs/react-motion-config) "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.

Gating the gesture with `useReducedMotion` did:

```tsx
const reduce = useReducedMotion()

<MotionButton
  whileHover={reduce ? undefined : { scale: 1.04 }}
  whileTap={reduce ? undefined : { scale: 0.97 }}
  transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
  Start free trial
</MotionButton>
```

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.

## Keep the focus ring when you animate a shadow

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`.

A hover glow is another story:

```tsx
<MotionButton whileHover={{ boxShadow: "0 0 24px rgba(139, 108, 255, 0.6)" }}>
  Glow on hover
</MotionButton>
```

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](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html), 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:

```tsx
<MotionButton
  className="focus-visible:ring-0 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
  whileHover={{ boxShadow: "0 0 24px rgba(139, 108, 255, 0.6)" }}
>
  Glow on hover
</MotionButton>
```

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.

## A checklist for every animated shadcn component

- 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.

For examples of motion that already follow these rules, the [Hold-to-Delete button](/react/hold-to-delete-button) and the [animated cart drawer](/react/animated-cart-drawer) are both built on the same stack. If you're comparing animated shadcn add-ons, [qodesign vs Magic UI](/compare/magic-ui) and [qodesign for shadcn/ui](/for/shadcn-ui) cover how they install, and [the free components](/free) are MIT. For the CSS-or-Motion choice in the first place, see [Framer Motion vs CSS for micro-interactions](/blog/framer-motion-vs-css-micro-interactions), and for forms, [the accessible animated login form](/blog/accessible-animated-login-form-react).

## FAQ

### Do shadcn/ui animations respect prefers-reduced-motion?

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.

### Does Framer Motion's MotionConfig reducedMotion="user" disable hover animations?

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 }}`.

### Why did my shadcn button lose its focus ring after adding a hover animation?

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.

### Will setting animation: none break Radix Dialog's focus trap?

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.
