GODRICH

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.

01Fade & scale

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. It's the safest default for confirm and alert dialogs, the kind a checkout button opens right before it charges a card.

200ms0.9→1페이드만
.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

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. That handle matters most on the narrow, mobile-width screens this pattern was built for, where a full modal would feel oversized.

280ms반만 열기손잡이
.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

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

320px왼쪽에서전체 폭
.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

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. It suits checkout screens and other high-stakes confirms, where blurring the background pulls attention harder than simply darkening it.

8px어둡게만12px
.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

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.

@starting-styleallow-discrete::backdrop
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

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

anchor위쪽화살표 없이
.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

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.

420ms둥글게 유지페이드 섞기
.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

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

4px두 번테두리 강조
.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

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

0.95어둡게3단
.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

Item 05's dialog only animates because two things work 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.

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.

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.

Accessibility

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.

The rest of this site's popup 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 replace all nine of these?

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.

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.