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

9 CSS Modal Animation Patterns, No Library Needed

A css modal animation is the motion a dialog uses to open and close, instead of snapping straight from hidden to visible.

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine tiles above open and close on their own, looping every two seconds inside a non-scrollable iframe that's too short to hold a real interaction — watch them rather than expecting a click to do anything, since none of the trigger buttons in that preview are wired up. 01 through 04 vary a single centered or edge-anchored panel by position and by what happens behind it. 05 and 06 hand the job to the browser itself, animating the native dialog element and a Popover API bubble instead of a hand-rolled overlay div. 07 through 09 add a second moving part: a thumbnail that becomes the modal, a shake that stands in for a close, and a second dialog that pushes the first aside. Once a takeover like 07 grows to cover a whole route rather than one panel, the CSS page transitions collect that larger size of change, and for entrances triggered by scrolling rather than by opening, the CSS scroll animations are the companion set.

01Fade & scale

What it does

A centered modal fades in while scaling up from 0.9 to 1 over roughly 200ms, then reverses on close — nothing slides, nothing changes color, just opacity and scale together.

Fade & scale
Open in a new tab

01 · Fade & scale — Measured area 24.692% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

200ms0.9→1페이드만

Where a css modal popup animation belongs

It's the safest default for confirm and alert dialogs, the kind a checkout button opens right before it charges a card.

.overlay {
  opacity: 0;
  pointer-events: none;
  transition: opacity $duration $easing;
}
.modal {
  transform: translate(-50%, -50%) scale(.92);
  opacity: 0;
  pointer-events: none;
  transition: opacity $duration $easing,
              transform $duration $easing;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .modal {
  opacity: 1;
  transform: translate(-50%, -50%) scale(1);
  pointer-events: auto;
}

02Bottom sheet

What you see

A sheet slides up from the bottom edge and stops flush against its own content height instead of filling the screen, with a small handle bar above the heading to signal that it's a sheet rather than a modal.

Bottom sheet
Open in a new tab

02 · Bottom sheet — Measured area 98.256% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

280ms반만 열기손잡이

Where it fits

That handle matters most on the narrow, mobile-width screens this pattern was built for, where a full modal would feel oversized.

.sheet {
  position: fixed;
  left: 50%; bottom: 0; top: auto;
  transform: translate(-50%, 100%);
  max-height: 62%;
  border-radius: $r-card $r-card 0 0;
  transition: transform $duration $easing;
}
.sheet__handle {
  width: 36px; height: 4px;
  margin: 0 auto;
  border-radius: $r-pill;
  background: rgba($stage-ink, .25);
}
.stage.is-open .sheet { transform: translate(-50%, 0); }

03Side drawer

The motion

A roughly 260px-wide panel slides in from the right edge on a spring easing that overshoots slightly before settling, rather than stopping dead the instant it reaches its final position.

Side drawer
Open in a new tab

03 · Side drawer — Measured area 100.0% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

320px왼쪽에서전체 폭

Good places for it

It reads as a cart or settings panel more than a confirmation, since a drawer implies there's more to browse than a single yes-or-no decision.

.drawer {
  position: fixed;
  right: 0; top: 0; bottom: 0; left: auto;
  transform: translateX(100%);
  width: min(78vw, 260px);
  transition: transform $duration $easing;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .drawer {
  transform: translateX(0);
  pointer-events: auto;
}

04Backdrop blur

What it does

The page behind the modal blurs from 0 to 8px on the same timing as the modal's own fade, so the two effects read as a single motion instead of two things firing independently.

Backdrop blur
Open in a new tab

04 · Backdrop blur — Measured area 100.0% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

8px어둡게만12px

When to use it

It suits checkout screens and other high-stakes confirms, where blurring the background pulls attention harder than simply darkening it.

.overlay {
  backdrop-filter: blur(0px);
  opacity: 0;
  transition: opacity $duration $easing,
              backdrop-filter $duration $easing;
}
.modal {
  transform: translate(-50%, -50%) scale(.94);
  opacity: 0;
  transition: opacity $duration $easing,
              transform $duration $easing;
}
.stage.is-open .overlay {
  opacity: 1;
  backdrop-filter: blur(8px);
}
.stage.is-open .modal {
  opacity: 1;
  transform: translate(-50%, -50%) scale(1);
}

05Native dialog

What you see

The <dialog> element opens and closes with a real transition instead of jumping instantly, because @starting-style defines what the "before" frame looks like for properties a browser would otherwise flip in a single tick. transition-behavior: allow-discrete keeps display and the dialog's own overlay property inside that transition rather than switching them the moment showModal() runs.

Native dialog
Open in a new tab

05 · Native dialog — Measured area 25.228% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

@starting-styleallow-discrete::backdrop

In the code

dialog.modal {
  opacity: 0;
  transform: scale(.92) translateY(10px);
  transition: opacity $duration $easing,
              transform $duration $easing,
              overlay $duration allow-discrete,
              display $duration allow-discrete;

  &[open] { opacity: 1; transform: none; }

  @starting-style {
    &[open] { opacity: 0; transform: scale(.92) translateY(10px); }
  }
}

06Popover confirm

The motion

A small confirm bubble appears directly above the delete button it belongs to, positioned in the top layer once the Popover API attaches it, with no arrow pointing back at the trigger.

Popover confirm
Open in a new tab

06 · Popover confirm — Measured area 10.85% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

anchor위쪽화살표 없이

Good places for it

It stands in for a full modal on low-stakes confirms like deleting a single row, where an entire overlay would be a lot of ceremony for a yes-or-no question.

.confirm {
  position: absolute;
  left: 50%; bottom: calc(100% + 10px);
  transform: translateX(-50%) translateY(6px) scale(.92);
  opacity: 0;
  transition: opacity $duration $easing,
              transform $duration $easing;
}
.confirm:popover-open {
  position: fixed;
  inset: auto;
  opacity: 1;
  transform: translateX(-50%) translateY(0) scale(1);
}

07Fullscreen takeover

What it does

The thumbnail that was pressed grows into a fullscreen modal over roughly 420ms without its width or height ever changing — the whole effect is one transform: scale running backward from a squashed corner to full size. A caption and a close button fade in only once that scale-up finishes, so the gallery tile stays a clean thumbnail until it's genuinely open.

Fullscreen takeover
Open in a new tab

07 · Fullscreen takeover — Measured area 96.809% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

420ms둥글게 유지페이드 섞기

In the code

.thumb {
  position: fixed; inset: 0;
  border-radius: $r-card;
  transform: translate(-30%, 20%) scale(.3);
  transform-origin: 20% 75%;
  transition: transform $duration $easing,
              border-radius $duration $easing;
}
.stage.is-open .thumb {
  transform: translate(0, 0) scale(1);
  border-radius: 0;
}
.thumb__caption, .thumb__close {
  opacity: 0;
  transition: opacity $duration $easing;
}
.stage.is-open .thumb__caption,
.stage.is-open .thumb__close { opacity: 1; }

08Shake on outside click

What you see

Pressing outside a required modal doesn't close it — the modal's border flashes red and the whole box shakes side to side twice, then settles back to its resting border color.

Shake on outside click
Open in a new tab

08 · Shake on outside click — Measured area 5.663% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

4px두 번테두리 강조

Where it fits

It's built for consent screens and required fields, where the interaction has to say "you can't skip this" without any extra text popping up to say so out loud.

.modal.is-shaking {
  animation: shake $duration cubic-bezier(.36, .07, .19, .97) both;
  border-color: $error;
}
@keyframes shake {
  10%, 90% { transform: translate(calc(-50% - 3px), -50%); }
  20%, 80% { transform: translate(calc(-50% + 4px), -50%); border-color: $error; }
  30%, 70% { transform: translate(calc(-50% - 4px), -50%); }
  40%, 60% { transform: translate(calc(-50% + 3px), -50%); }
  50%      { transform: translate(calc(-50% - 3px), -50%); border-color: $error; }
}

09Stacked modals

The motion

Opening a second modal doesn't hide the first one — it pushes it back 16px, scales it down to 0.95, and drops its opacity to 0.55, so the first screen stays visible as context sitting behind the second.

Stacked modals
Open in a new tab

09 · Stacked modals — Measured area 28.221% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

0.95어둡게3단

Good places for it

It's built for multi-step flows like an address form followed by a postal-code lookup, where losing the first screen entirely would make someone forget what they were filling in.

.modal--1 {
  transform: translate(-50%, -50%);
  transition: transform $duration $easing,
              opacity $duration $easing;
}
.modal--2 {
  transform: translate(-50%, -50%) translateY(10px) scale(.9);
  opacity: 0;
  transition: transform $duration $easing,
              opacity $duration $easing;
}
.stage.is-stacked .modal--1 {
  transform: translate(-50%, calc(-50% - 16px)) scale(.95);
  opacity: .55;
}
.stage.is-stacked .modal--2 {
  transform: translate(-50%, -50%) translateY(0) scale(1);
  opacity: 1;
  pointer-events: auto;
}

Where it breaks — two traps

Why 05 needs @starting-style

Item 05's css modal open animation only works because two things come together: @starting-style, which defines the "before" frame for properties a browser would otherwise flip in an instant, and allow-discrete on the display and overlay properties, which keeps them inside the transition instead of switching the moment showModal() fires. Drop either one and the dialog still opens — it just snaps into place with no animation at all, since display and the top-layer overlay property are discrete by default and skip any transition that doesn't explicitly opt them in. Support is recent enough to matter here: native <dialog> itself works from Chrome 37, Safari 15.4, and Firefox 98, per MDN's dialog element reference, but @starting-style needs Chrome 117, Safari 17.5, or Firefox 129, per MDN's starting-style page — an older Firefox still opens the dialog, it just skips the fade.

Trap two — the preview loop

The second trap sits in the demo markup, not the CSS itself: every tile in the grid runs on an .is-demo class and a set of loop keyframes that exist only to animate the preview automatically, entirely separate from the real .is-open, [open], and :popover-open styles a genuine click triggers. Copy the .is-demo rules straight into a production build and the modal keeps looping every two seconds no matter what a visitor does, since none of those keyframes listen for a real interaction — the archive's password is udnptj2b if you'd rather see where the demo loop ends and the real behavior begins than guess from the SCSS alone.

Popover support

Item 06's Popover API carries its own version gap: Chrome shipped it at 114, Safari at 17, and Firefox trailed to 125, per MDN's Popover API guide, and the confirm bubble only gets top-layer positioning and the :popover-open styling once the popover attribute actually lands on the element — this demo sets it with JavaScript on the first click rather than baking it into the markup up front. When a stack like 09 should also read in time rather than only in depth, the delay math in the CSS stagger animation demos drops in unchanged to space the two dialogs apart.

Accessibility

Reduced motion

None of the nine reduce motion the same way, because "reduced" means something different for a shake than it does for a fade. 01 through 04 drop their entrance transition entirely and are simply open or closed, without the scale, slide, or blur ever running. 07's thumbnail-to-fullscreen transform collapses to an instant swap too, and its caption and close button appear right away instead of fading in a beat later. 08 changes behavior rather than just speed: the shake keyframes disappear and the border switches straight to red, so the modal still visibly refuses to close without any motion doing the telling. 09 still changes scale and opacity when a second modal opens, only in 1ms instead of across the spring easing, so the stacking is still legible without the animation. MDN's guide to the prefers-reduced-motion media feature has the current browser support numbers.

More css popup animation examples and overlay patterns sit under the CSS section, and the about page covers why every post here ships a working demo instead of a screenshot.

FAQ

Does the native dialog element make every css dialog animation here redundant?

Mostly, for anything that already behaves like a real dialog. <dialog> with showModal() gives a focus trap, Escape-to-close, and a ::backdrop for free, all of which 01 through 04 build by hand with a plain div and a keydown listener. It doesn't help with 02's handle bar, 07's thumbnail transform, or 09's second dialog stacking behind the first, since those are layout choices <dialog> has no opinion on either way. The one control most likely to sit beside 03's drawer is the menu button that opens it, and the CSS hamburger menu animations cover matching that icon's open-and-close to the panel's.

Why does 05 combine a transition with @starting-style instead of plain keyframes?

A looping @keyframes animation, like the one driving the grid preview, can't respond to a real showModal() or close() call because it has no idea when either one happens. A transition on [open] does know, but display and the browser's overlay property are discrete, so without @starting-style and allow-discrete the browser just swaps them at the edge of the transition instead of animating across it. Keyframes and transitions are solving different problems here — looping on a timer versus responding to a real state change.

Will these work with Tailwind or inside a React build?

Yes — the motion lives entirely in transitions, custom properties, and one @starting-style block, none of which conflict with Tailwind's utility classes or a component's own class names. The zip's react/ folder wraps each pattern in a component that takes the same three variables as props, so switching frameworks doesn't mean rewriting any of the animation.

Enter the archive password

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