GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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

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 like react css transition group to get. If the open question is which curve to hand a component, the CSS easing curves and springs are that catalogue, and a screen whose numbers change with state is close at hand in the CSS pricing table animations.

01Mount fade via @starting-style

Where react css animation on mount fits

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.

Mount fade via @starting-style
Open in a new tab

01 · Mount fade via @starting-style — Measured area 19.029% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

@starting-style@keyframes mf-mountReact 19

Worth noting

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

The moment it fires

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.

Unmount exit (allow-discrete)
Open in a new tab

02 · Unmount exit (allow-discrete) — Measured area 22.772% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

allow-discreteonTransitionEndexit class

The key property

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

What the user notices

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.

List add/remove animation
Open in a new tab

03 · List add/remove animation — Measured area 14.355% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

key@keyframes la-enter@keyframes la-leave

What to read in the code

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

Where this shows up

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.

Toggle-class state transition
Open in a new tab

04 · Toggle-class state transition — Measured area 1.805% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

useStateclassNametransition

How it is built

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

What happens on screen

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.

Re-trigger via key prop
Open in a new tab

05 · Re-trigger via key prop — Measured area 4.161% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

key propremount@keyframes

How it works

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

What it looks like

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.

CSS Modules + SCSS setup
Open in a new tab

06 · CSS Modules + SCSS setup — Measured area 1.792% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

*.module.scssstyles.cardscoped classname

How to wire it up

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

When to use it

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.

startViewTransition + state update
Open in a new tab

07 · startViewTransition + state update — Measured area 30.558% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

startViewTransitionflushSync@keyframes vt-cross-in

The core of it

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

What you see

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.

Suspense skeleton swap
Open in a new tab

08 · Suspense skeleton swap — Measured area 4.592% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

React.lazySuspense fallbackshimmer

The idea behind it

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

Where it fits

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.

Route transition animation
Open in a new tab

09 · Route transition animation — Measured area 30.983% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

key by routestartViewTransitionexit/enter

Why it is done this way

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?

What goes wrong

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.

How to avoid it

An animation restarting from zero on every re-render hurts most on surfaces that are supposed to keep flowing, like the aurora mesh gradient landing sections.

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

What reduced motion leaves

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.

What else to check

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 need a library, or is css transition in react enough?

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. If the real question is what to show while a route is changing, the page transition loading states fill that gap.

Enter the archive password

The password is inside this article. You will find it as you read.