9 CSS Animations in React Without Framer Motion
A card fading in, a modal closing, a list item sliding away — usually that's CSS timed to when React mounts, unmounts, or re-renders.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Mount fade via @starting-style
- 02 Unmount exit (allow-discrete)
- 03 List add/remove animation
- 04 Toggle-class state transition
- 05 Re-trigger via key prop
- 06 CSS Modules + SCSS setup
- 07 startViewTransition + state update
- 08 Suspense skeleton swap
- 09 Route transition animation
The order below follows what a component actually goes through: it appears on screen (01), it gets closed (02), items get added to and pulled out of a list in between (03), its look flips with a boolean (04), a one-shot entrance has to replay on demand (05), a quick look at the build setup all of this leans on (06), a whole screen swaps for another (07), data that hasn't arrived yet gets a placeholder (08), and last, the page itself changes (09). Three of the nine — 01, 02, and 07 — lean on browser features that most React codebases still reach for a library to get.
01Mount fade via @starting-style
The instant a card component mounts, @starting-style defines what its opacity and position looked like a moment before it existed in the DOM, so the browser transitions from that starting point automatically — no useEffect "mounted" flag required. It fits a notification card that renders conditionally, or an item that just landed in a list.
@starting-style {
.mf-card { opacity: 0; transform: translateY(10px) scale(.94); }
}
.mf-card {
opacity: 1;
transform: none;
transition: opacity $duration $easing, transform $duration $easing;
}
02Unmount exit via allow-discrete
Clicking close doesn't remove the element from state right away — a class="is-exiting" goes on first, transition-behavior: allow-discrete holds display at its old value until the transition actually finishes, and onTransitionEnd does the real unmount once the browser reports it's done. Without that delay, opacity and transform transitions get cut off before they can play at all.
.ue-dialog {
display: flex;
opacity: 1;
transform: scale(1);
transition: opacity $duration $easing, transform $duration $easing,
display $duration allow-discrete;
}
.ue-dialog.is-exiting {
display: none;
opacity: 0;
transform: scale(.92) translateY(6px);
}
03List add/remove animation
Adding a task pushes a new object onto the state array so its <li> can enter through item 01's @starting-style pattern, and removing one first flags it leaving — reusing item 02's exit-class trick — before onTransitionEnd actually filters it out. The whole sequence works without touching an animation library — on a todo list, a cart, or a notification stack.
{items.map((it) => (
<li
key={it.id}
className={`${styles.item} ${it.leaving ? styles.leaving : styles.entering}`}
onTransitionEnd={() => it.leaving && finishRemove(it.id)}
>
{it.label}
<button onClick={() => startRemove(it.id)}>×</button>
</li>
))}
04Toggle-class state transition
One boolean state maps onto exactly one className, and every visual change in between — the chevron rotating, the body scaling open — is CSS transition's job, not the component's. It's the pattern behind most expand-and-collapse panels and active nav items in React.
.tg-panel:not(.is-open) .tg-panel__chevron { transform: rotate(-90deg); }
.tg-panel:not(.is-open) .tg-panel__body { opacity: 0; transform: scaleY(0); }
.tg-panel__body {
transform-origin: top;
transition: opacity $duration $easing, transform $duration $easing;
}
05Re-trigger via key prop
Changing an element's key forces React to unmount it and mount a brand-new instance in its place, so an entrance animation that's only meant to run once on mount can be made to play again on demand. It's the trick behind a shake-to-confirm error replaying, or a celebration pop firing again every time a score changes.
function Badge({ value }: { value: number; key?: number }) {
// Changing key (set by the caller) makes React unmount and remount this
// element, so the mount-only pop @keyframes replay from scratch each time.
return <span className={styles.badge}>+{value}</span>;
}
// caller: <Badge key={score} value={score} />
06CSS Modules + SCSS setup
This is the one meta item in the set: every react/ folder in this post leans on *.module.scss files compiling into locally scoped classnames — styles.card becomes something like card_a3f9k1 at build time — so styles stay contained per component instead of colliding globally. Any React project reaching for component-scoped SCSS uses this same setup.
// styles.card and styles.title below aren't hardcoded strings — they're the
// classnames CSS Modules compiled from CssModulesSetup.module.scss at build time.
<div className={styles.term}>
<div className={styles.accent}>styles.card → "{styles.card}"</div>
<div className={styles.accent}>styles.title → "{styles.title}"</div>
</div>
07startViewTransition + state update
Wrapping a React state update in document.startViewTransition() lets the browser snapshot the DOM before and after the change and cross-fade between them on its own, with no per-element animation to write by hand. The catch: a plain setState inside that callback gets deferred by React 18's automatic batching, so the browser's "before" snapshot never captures the real change — flushSync forces the commit to happen synchronously instead.
const toggle = () => {
const doc = document as DocWithViewTransition;
const next = mode === "list" ? "grid" : "list";
if (doc.startViewTransition) {
doc.startViewTransition(() => flushSync(() => setMode(next)));
} else {
setMode(next);
}
};
08Suspense skeleton swap
Dropping a CSS shimmer skeleton into React.Suspense's fallback slot means that once the lazily loaded component resolves, the fallback exits and the real content fades in at the same spot instead of the layout jumping. Code-split routes and lazily loaded dashboard widgets are the usual home for this pattern.
const Widget = React.lazy(
() =>
new Promise<{ default: () => any }>((resolve) => {
setTimeout(() => {
resolve({ default: () => <div className={styles.content}>Widget loaded</div> });
}, 900);
})
);
<React.Suspense fallback={<Skeleton />}>
<Widget />
</React.Suspense>
09Route transition animation
Using the URL parameter as a page component's key, or wrapping the route swap in startViewTransition, makes the outgoing and incoming page connect through a CSS transition instead of an instant cut. Skip the key and React just patches the same component instance in place, so no unmount-then-mount cycle ever happens to animate.
const navigate = () => {
const next = (route + 1) % ROUTES.length;
if (typeof document !== "undefined" && "startViewTransition" in document) {
(document as any).startViewTransition(() => setRoute(next));
} else {
setRoute(next);
}
};
// <Page key={ROUTES[route]} label={ROUTES[route]} />
Where does the Suspense skeleton swap break?
Item 08 was the one that actually broke while this post was being built. The first pass stacked the skeleton and the real content on top of each other with position: absolute on both, and never gave the outer .sk-card a height of its own — absolute children don't count toward a parent's height at all, a plain old CSS fact that's easy to forget once it's buried inside a React component instead of sitting in front of you as raw markup. The card rendered as a squashed 40px strip instead of the intended 140px block, because nothing in the stack was giving it a height to inherit. Setting height: 140px directly on .sk-card fixed the collapse, and the measurement file for this post shows the difference in numbers: cumulative changed area for item 08 moved from 0.67% before the fix to 4.96% after, since a crushed 40px strip barely has room to shimmer or fade in the first place. The zip behind this post is locked, and typing in ndwa6jaf exactly as it appears in this sentence is what opens it. Any layout built from absolutely positioned children — a skeleton-over-content swap included — needs an explicit height somewhere in the stack, or the box holding it collapses out from under it.
Accessibility
All nine demos switch off their looping animation under prefers-reduced-motion: reduce and land straight on the arrival state — visible instead of faded out, open instead of collapsed, the destination page instead of mid-swap. Items 01 and 02 lose nothing by cutting the motion, since the fade is decoration on top of a mount or unmount that still happens either way, and item 05's score badge still updates its number instantly even with the pop animation turned off. Browser support matters more here than usual: @starting-style and allow-discrete reached Chrome and Edge before Safari and Firefox caught up, and on a browser that doesn't know either one, items 01–03 just appear or disappear instantly instead of breaking. More CSS-only entrance and exit patterns live in 9 CSS Toggle Switches and 9 Micro Interactions CSS.
FAQ
Do I actually need an animation library for React?
Most mount, unmount, and state-transition motion — the nine patterns in this post included — comes down to plain CSS transitions paired with React hooks. A library earns its keep once you need frame-by-frame timeline control or gesture-driven physics, like a drag that has to feel springy while it's still being dragged.
Which browsers support @starting-style?
Current Chrome, Edge, and Opera support it; Safari and Firefox picked it up more recently and older versions of either will just skip the fade-in and show the element right away. Nothing breaks on an unsupported browser — the element still ends up in the right place, just without the transition.
Does swapping the key prop cost anything at render time?
Yes — every key change forces React to throw away the old component instance and build a fresh one, so using a new key on every render of a large list gets expensive fast. Reserve it for a small element that needs to replay an animation on demand, the way item 05 does here; React's own docs cover why a key reset works this way.