TL;DR Open the files in components/ui and change a few defaults: swap transition-all for named properties, add one --ease-* token in @theme and use it everywhere, give buttons a small press, make menus zoom from 98% and close faster, stop dialogs zooming from the center, use a soft ring and shadow on cards, and add tabular-nums to changing numbers. No new packages.
Most shadcn/ui apps look alike for a boring reason: nobody opened the component files after add. The theme gets new colors, but the button still fades on transition-all, every menu zooms from 95%, every dialog grows out of the middle of the screen, and every card is a 1px border with shadow-sm. Those defaults are fine. They are also on thousands of other sites, so people notice them.
I make qodesign, a small shop of animated React and Tailwind pieces, so I spend a lot of time in these files. The tweaks below are small edits to the shadcn components you already own: class strings and one theme token, no new packages. The code in this post was written for it and needs only React and Tailwind v4. It compiles with strict TypeScript, and I checked that our own Tailwind 4 install generates every core utility it uses.
If your problem is the page as a whole (the default theme, one font everywhere, the centered gradient hero), start with How to Make UI Not Look AI-Generated: A v0 Checklist. This post picks up after that, inside components/ui.
It helps to read the source before changing it. In the current new-york-v4 style:
Button uses transition-all with no easing or duration class, so it gets Tailwind's defaults: 150ms on cubic-bezier(0.4, 0, 0.2, 1) for every property that changes.
DropdownMenuContent animates in with fade-in-0 zoom-in-95 and out with fade-out-0 zoom-out-95, plus a 2 unit slide from the side it opens on. It sets origin-(--radix-dropdown-menu-content-transform-origin), so it already scales out of the trigger. Keep that class.
DialogContent uses the same zoom-in-95 with duration-200, and the overlay is bg-black/50.
Those animate-in and zoom-in-95 classes come from tw-animate-css. Its source is worth a look for one detail: the animations read --tw-duration and --tw-ease, falling back to 150ms and plain CSS ease. So Tailwind's normal duration-* and ease-* classes work on these animations too, and if you set neither, every popover in your app runs on ease.
Hold to Delete running live. A delete button you press and hold, then the file crumples into the bin.
The fastest way to make motion feel designed is to stop mixing curves. Add one to your theme:
globals.css:
css
@import "tailwindcss";@theme { /* one curve for every UI transition: starts fast, settles gently */ --ease-snappy: cubic-bezier(0.2, 0, 0, 1);}
In Tailwind v4 any --ease-* variable in @theme becomes a utility, so this gives you ease-snappy. I checked the generated CSS: it sets both transition-timing-function and --tw-ease, which means the same class drives Tailwind transitions and tw-animate-css animations. The exact numbers are taste. What matters is that buttons, menus, dialogs and cards all share them.
transition-all animates anything that changes, including things you never meant to animate, like padding when a loading spinner appears. Name the properties instead, give the button a press state, and keep the focus ring. Here is a button with the same job as shadcn's buttonVariants, written without dependencies so you can paste the strings into your own file:
button.tsx:
tsx
import * as React from "react"// Same job as shadcn's buttonVariants, written out so the post has no dependencies.// If you use cva, paste these strings into your existing buttonVariants instead.const base = "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap " + "transition-[color,background-color,box-shadow,scale] duration-150 ease-snappy " + "active:not-aria-[haspopup]:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100 " + "outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 " + "disabled:pointer-events-none disabled:opacity-50"const variants = { default: "bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.15),0_1px_2px_rgb(0_0_0/0.2)] hover:bg-primary/90", outline: "bg-background ring-1 ring-black/10 shadow-xs hover:bg-accent hover:text-accent-foreground dark:ring-white/15", ghost: "hover:bg-accent hover:text-accent-foreground",} as constconst sizes = { default: "h-9 px-4", sm: "h-8 px-3", lg: "h-10 px-6",} as consttype ButtonProps = React.ComponentProps<"button"> & { variant?: keyof typeof variants size?: keyof typeof sizes}export function Button({ className = "", variant = "default", size = "default", ...props }: ButtonProps) { return ( <button data-slot="button" className={`${base} ${variants[variant]} ${sizes[size]} ${className}`} {...props} /> )}
A few choices in there:
active:scale-[0.97] gives a small press. Newer shadcn styles do something similar with a 1px translate-y-px on :active. Either works; pick one and keep it.
not-aria-[haspopup] skips the press on buttons that open a menu, so the trigger does not shrink while the menu scales out of it. That idea comes from shadcn's own newer button classes.
The default variant gets a faint inset highlight on top and a tight shadow under it, so it looks raised instead of flat. The outline variant uses a ring at 10% black instead of a solid border, which is softer on light backgrounds.
motion-reduce: turns the press and the transition off for people who ask their OS for less motion.
A 95% zoom is a lot for a menu that is 200px wide. Drop it to 98%, and let the menu close faster than it opens. People wait for something to appear, but they have already moved on when it disappears.
motion-classes.ts:
ts
// Drop these into the className of the matching shadcn component (tw-animate-css utilities).// DropdownMenuContent, PopoverContent, SelectContent: smaller scale, quicker exit.// Keep shadcn's origin-(--radix-...-transform-origin) class so it still grows out of the trigger.export const menuContentMotion = "data-[state=open]:zoom-in-98 data-[state=closed]:zoom-out-98 " + "data-[state=open]:duration-150 data-[state=closed]:duration-100 ease-snappy"// DialogContent: rise a few pixels instead of zooming out of the middle of the screen.export const dialogContentMotion = "data-[state=open]:zoom-in-100 data-[state=closed]:zoom-out-100 " + "data-[state=open]:slide-in-from-bottom-2 data-[state=closed]:slide-out-to-bottom-1 " + "data-[state=open]:duration-200 data-[state=closed]:duration-150 ease-snappy"// DialogOverlay: lighter tint plus a small blur reads calmer than flat black at 50%.export const dialogOverlayLook = "bg-black/30 backdrop-blur-[2px]"
The cleanest way to use these is to edit the class string in components/ui/dropdown-menu.tsx (and the popover, select and dialog files): replace zoom-in-95 and zoom-out-95 with the classes above. Passing them through className also adds them, but cn uses tailwind-merge, which may not know the tw-animate-css classes, so both zoom values can end up on the element and you are left guessing which one wins. The duration classes are safe either way, because a data-[state=...]: variant is more specific than a plain duration-200. In tw-animate-css a number means a percentage, so zoom-in-98 starts the menu at 98% scale.
The default dialog grows from 95% in the center of the screen, which is the motion most people now read as a template. dialogContentMotion above sets the scale to 100% so there is no zoom at all, and slides the dialog up 2 spacing units as it fades in. It drops 1 unit on the way out, a bit faster.
This works with shadcn's centering because the two use different CSS properties. Tailwind v4's translate-x-[-50%] sets the translate property, and the tw-animate-css keyframes animate transform, so the slide adds to the centering instead of replacing it.
For the overlay, bg-black/30 backdrop-blur-[2px] is lighter than the default black at 50% and keeps a hint of the page behind it. Blur can be heavier to paint than a flat color, so keep it small and try it on a cheap phone. If you want to push that blur all the way to frosted glass, the liquid glass post covers what renders in Chrome, Safari and Firefox and what to fall back to.
The stock card is border plus shadow-sm. A 1px ring at 5% black with a long, soft shadow under it looks more like a surface and less like a box. On hover, lift it half a spacing unit and deepen the shadow:
Note transition-[translate,box-shadow]. In Tailwind v4, -translate-y-0.5 sets the translate property, not transform, so translate is the property to name. I checked that in the generated CSS too.
Prices, counters, timers and stats should use tabular-nums. It sets font-variant-numeric: tabular-nums, which gives every digit the same width, so a value going from 1,111 to 9,999 does not shift the text next to it. shadcn uses it in its own docs examples. It is one class and it makes a dashboard feel steadier right away. If you animate numbers, Animated Number Counter in React, No Library shows the counting part.
The arrow in the card above moves 2px to the right when you hover the card, using group on the link and group-hover:translate-x-0.5 on the icon. Small movement on a small element tells people something is clickable without the whole layout moving. The same trick works for a chevron in a button or a "View all" link.
Every snippet here has a motion-reduce: class next to its motion. That is the minimum. If you want the full picture for shadcn components, including whether Radix dialogs still trap focus with their animations turned off, read shadcn/ui Animated Components: Add Motion Without Breaking Accessibility.
AI tools add shadcn components with the stock classes every time, so put the rules where the tool reads them. Something like:
text
When you add or edit a shadcn/ui component:
- Never use transition-all. Name the properties (color, background-color, box-shadow, scale, translate).
- Use ease-snappy and duration-150 for small UI, duration-200 for dialogs.
- Popovers and menus: zoom-in-98 / zoom-out-98, exit faster than enter. Keep the origin-(--radix-...) class.
- Dialogs: no zoom, slide-in-from-bottom-2.
- Every motion class gets a motion-reduce: counterpart.
- Use tabular-nums for any number that changes.
Once the basics feel right, one component with real motion gives the page something people remember. The Hold to Delete button below is one of mine: a destructive action that fills while you hold it, so nobody deletes by accident. If you would rather build that idea yourself, Hold to Delete Button in React and Tailwind walks through it, and the paid piece is at /components/hold-to-delete.
Change the theme first, then the component files. In the components, the biggest wins are small: replace transition-all, use one easing curve, make menus scale less and close faster, stop dialogs zooming from the center, and swap card borders for a soft ring and shadow.
Yes. shadcn's animate-in and animate-out come from tw-animate-css, which reads --tw-duration and --tw-ease. Tailwind's duration-* and ease-* classes set those variables, so they control the enter and exit animations as well as normal transitions.
No. Everything here is Tailwind classes plus the tw-animate-css utilities shadcn already installs. Reach for a motion library when you need gestures, layout animation or springs.
The files in components/ui are plain source in your repo, so they are yours to edit. If you run add again for a component you changed, read the diff before you keep it, and keep the shared bits (the easing token, the class strings in motion-classes.ts) in your own files so they are easy to put back.