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

Modal Animation Examples: 9 Open and Close Effects

Modals get a careful entrance, then just vanish. Here 03 opens in a circle sized to the farthest corner, 05 appears cell by cell past a noise threshold, and 07

A Stage Where Nine Modals Open and Close Differently

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

Every cell runs a 2,400 ms loop. The window starts to open at 200 ms, and each item switches to its close at its own moment: 1,400 ms for 02, 1,450 ms for 03 and 04, 1,500 ms for 01 and 05, 1,850 ms for 06, and 1,150 ms for 07 and 08. In 07 the loop closes as if "Add to cart" had been pressed and in 08 as if "Delete" had, so one window drops into the cart and the other shatters, while 09 presses "Confirm" at 900 ms and runs the press, the check mark, and the shrink in a row.

Modal Animation: 9 Effects — Spring Pop, Grow From the Button, Circle Mask, Paper Fold, Pixel Dissolve, Ripple, Funnel Close, Glass Shards and a Checkout Confirm Flow

The first six differ in how they open, 07 and 08 are about how they leave, and 09 packs a whole round trip of open, press and close into one window. The stylesheets hold zero @keyframes. Motion comes from a loop that takes the time handed over by requestAnimationFrame, slices it into fixed 16 ms ticks, and on each tick solves a spring, an interpolation, a noise threshold test, a wave grid, or shard physics, and the close is computed on its own path instead of replaying the open in reverse.

Canvas paints the text while a transparent DOM layer takes the clicks

What you see in each cell, the window, its buttons, the title, and the body text, is a picture redrawn every tick on a canvas that works in 480×300 scene coordinates. That canvas carries aria-hidden="true", and right on top of it sits a DOM layer with color: transparent: the opener button, a dialog with role="dialog", aria-modal="true", aria-labelledby and aria-describedby, and its two buttons, which receive the keyboard, the pointer and the screen reader. The effects can slice, stretch and bend the window because makeSprite paints it ahead of time onto three offscreen canvases (one complete with background, one without background, one without the confirm button) and then moves pieces of those pictures with drawImage. None of the nine sources calls getImageData. The price of splitting painted text from real text is covered in the questions at the end: for a production modal it is usually better to put the same math on DOM text through transform and clip-path.

What 24 captured frames recorded for each window

A two-second render at 480×300 gave 24 frames per item, which means 23 steps between them; the frame column counts the steps where the picture changed.

Window Cumulative area Moving frames Mean intensity
01 Spring pop 74.8% 12/23 46.8
02 Grow from the button 75.8% 13/23 55.6
03 Circle mask 67.1% 15/23 94.2
04 Paper fold 75.8% 16/23 50.9
05 Pixel dissolve 75.5% 17/23 36.9
06 Ripple refraction 75.9% 21/23 34.4
07 Funnel close 75.9% 20/23 41.4
08 Glass shards 75.9% 17/23 45.8
09 Checkout confirm flow 75.9% 19/23 47.1

The cumulative area sits between 67% and 76% for all nine, and that is not because the windows travel far. When a window opens, a dark scrim with a peak opacity of 0.46 spreads over the whole stage and later lifts, so every pixel it passes counts as changed. 03 reads lower at 67.1%, likely because it paints the scrim and the window only inside its circle. 01 has the fewest moving frames at 12/23, which appears to come from the spring settling around 480 ms and the picture holding still until the close, and 06 climbs to 21/23, likely because its ripple keeps shaking the window until 1,504 ms.

Screens these windows were written for

Each window plays one real screen. 01 is a sign-up prompt, 02 a quick view from a product list, 03 the notification detail behind an alerts badge, 04 a terms agreement, 05 a coupon win announcement, and 06 a summer feature announcement. 07 is the window where you pick an option and add it to the cart, 08 confirms a file deletion, and 09 is a final order confirmation for a made-up order that shows no payment method and no amount.

When a shop or a booking flow needs a modal, the content tends to get the attention and the moment it appears and disappears gets little. A popup animation effect earns its place when the close says what just happened: in 07 the item travels into the cart, and in 08 the shattering says the file is gone. That is also why 08 does not break when it closes through "Cancel".

01A Modal That Springs Up as Its Shadow Deepens

The window starts at 0.55 scale and grows by a spring with stiffness 150 and damping 14, integrated every 16 ms, and at a damping ratio of 0.57 it swells once to 104.1% before staying within 1% of its target from 480 ms. The lift follows the size, so shadow blur and drop deepen together and reach 28.4 px at the overshoot, past SHADOW_LIFT_MAX of 26, which gives a sign-up prompt a light, friendly arrival. In the render it covered 74.8% of the stage and moved in 12/23 frames at a mean intensity of 46.8.

A Modal That Springs Up as Its Shadow Deepens
Open in a new tab

01 · A Modal That Springs Up as Its Shadow Deepens — Measured area 74.769% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPRING_STIFFNESSSPRING_DAMPINGSHADOW_LIFT_MAX
    // One damped-spring step: acceleration = -stiffness x (position - target) - damping x velocity. Velocity is updated first, then position moves by that velocity. dt is in seconds
    function springStep(s, target, stiffness, damping, dt) {
        const a = -stiffness * (s.x - target) - damping * s.v;
        s.v += a * dt;
        s.x += s.v * dt;
    }
    function springRest(s, target) { return Math.abs(s.x - target) < 0.002 && Math.abs(s.v) < 0.02; }
    // Size: once the spring value passes 1, the window is larger than its target (overshoot)
    function popScale() { return SCALE_FROM + (1 - SCALE_FROM) * pop.x; }
    // Lift height: proportional to size. At the overshoot it rises above the target height, so the shadow is deeper and farther
    function popLift() { return SHADOW_LIFT_MAX * Math.max(0, (popScale() - SCALE_FROM) / (1 - SCALE_FROM)); }
    function itemPage() { drawOpener(1); }
    function itemDraw() {
        if (pop.x <= 0.001) {
            return;
        }
        drawScrim(pop.x);
        drawPanelScaled(spriteFull, popScale(), pop.x * 1.8, popLift());
    }

02A Modal That Grows Out of Its Button

The 128×34 "Quick view" button's rectangle is interpolated into the 272×172 window over 520 ms, with position, width and height moving together and the corner radius going from 17 to 14. The parent layer is stretched by sx and sy while the child layer is scaled back by 1/sx and 1/sy, so the product name inside a quick view keeps its own proportions the whole way. Measured: 75.8% cumulative area, 13/23 moving frames, mean intensity 55.6.

A Modal That Grows Out of Its Button
Open in a new tab

02 · A Modal That Grows Out of Its Button — Measured area 75.818% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

GROW_DURATION_MSCORNER_RADIUS_FROMCORNER_RADIUS_TO
    function itemDraw() {
        if (grow <= 0) {
            return;
        }
        const e = easeInOutCubic(grow);
        drawScrim(e);
        const r = growRect(e);
        const rad = lerp(CORNER_RADIUS_FROM, CORNER_RADIUS_TO, e);
        const sx = r.w / PANEL.w;
        const sy = r.h / PANEL.h;
        ctx.save();
        ctx.translate(r.x, r.y);
        // Parent layer: a coordinate system that stretches the whole window by sx, sy (in the DOM this is one line, transform: scale(sx, sy) on the window)
        ctx.scale(sx, sy);
        // Divide the radius by the scale, so after stretching the corners still read as circles of radius rad
        roundRectPath(ctx, 0, 0, PANEL.w, PANEL.h, rad / sx, rad / sy);
        ctx.shadowColor = rgba(SCRIM, 0.16 + 0.18 * e);
        ctx.shadowBlur = (6 + 24 * e) * dpr;
        ctx.shadowOffsetY = (3 + 10 * e) * dpr;
        ctx.fillStyle = rgba(mix(ACCENT, PANEL_BG, smooth(e * 1.6)), 1);
        ctx.fill();
        ctx.shadowColor = 'rgba(0,0,0,0)';
        ctx.clip();
        // Child layer: scale back by the inverse 1/sx, 1/sy so the text inside does not look stretched
        ctx.scale(1 / sx, 1 / sy);

03A Modal Opening in a Circle Sized to the Farthest Corner

The circle is centered on the opener button at (396, 265), and of the four distances to the stage corners, 476.5, 278, 397.5 and 91 px, the largest one, 476.5 px to the top left, becomes the final radius. That radius grows on an ease-out cubic curve over 700 ms, so the circle covers the stage at the exact moment the opening ends, a good match for a notification detail that unfolds from its badge. Its render scored 67.1% cumulative area, 15/23 moving frames and a mean intensity of 94.2, the highest of the set.

A Modal Opening in a Circle Sized to the Farthest Corner
Open in a new tab

03 · A Modal Opening in a Circle Sized to the Farthest Corner — Measured area 67.108% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

IRIS_ORIGIN_XIRIS_ORIGIN_YIRIS_DURATION_MS
    // Final radius: the largest distance from the start point to the four stage corners. At this radius the circle covers the stage with no gaps
    function irisMaxRadius(ox, oy) {
        const corners = [[0, 0], [W, 0], [0, H], [W, H]];
        let m = 0;
        for (let i = 0; i < corners.length; i += 1) {
            m = Math.max(m, Math.hypot(corners[i][0] - ox, corners[i][1] - oy));
        }
        return m;
    }
    const IRIS_X = IRIS_ORIGIN_X * W;
    const IRIS_Y = IRIS_ORIGIN_Y * H;
    const IRIS_R = irisMaxRadius(IRIS_X, IRIS_Y);

04A Modal That Unfolds in Three Panels Like Paper

The window is cut into three panels 57.33 px tall that start folded at 84° and flatten over 760 ms, and every panel is sliced into 2 px strips that get a perspective scale from their depth, with the far edge going from 0.901 to 1. Brightness is the cosine between each panel's normal and the light, so the panel facing away starts at 0.42 and the one turned toward the light peaks at 1.03, a paper-like reveal for a terms agreement. Measured: 75.8% area, 16/23 frames, mean intensity 50.9.

A Modal That Unfolds in Three Panels Like Paper
Open in a new tab

04 · A Modal That Unfolds in Three Panels Like Paper — Measured area 75.815% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

FOLD_PANELSFOLD_START_DEGFOLD_SHADE_MIN
    // Current panel angle (radians)
    function foldAngle() { return FOLD_START_DEG * (1 - easeInOutCubic(fold)) * Math.PI / 180; }
    // Panel brightness: cosine of the angle between the panel normal and the light direction. Facing straight out gives 1, and the further it lies back away from the light, the darker it gets toward FOLD_SHADE_MIN.
    // The share above 1 (a panel tilted toward the light) comes back as a highlight
    function foldShade(th, dir) {
        const flat = -LIGHT_Z;
        const lit = (dir * Math.sin(th) * LIGHT_Y - Math.cos(th) * LIGHT_Z) / flat;
        return FOLD_SHADE_MIN + (1 - FOLD_SHADE_MIN) * Math.max(0, lit);
    }

05A Modal Whose Pixels Resolve Past Per-Cell Noise Thresholds

With 6 px cells the window splits into 46×29 = 1,334 cells, and two layers of value noise from seed 4735 give each cell its own threshold. Over 800 ms only cells whose threshold the progress has passed get painted, the ones that just crossed glow for a moment, and by 50% progress 66.5% of the cells are already in, which makes a coupon win feel like it materializes. It reached 75.5% cumulative area, 17/23 moving frames and a mean intensity of 36.9.

A Modal Whose Pixels Resolve Past Per-Cell Noise Thresholds
Open in a new tab

05 · A Modal Whose Pixels Resolve Past Per-Cell Noise Thresholds — Measured area 75.538% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

DISSOLVE_CELL_PXNOISE_SCALEDISSOLVE_DURATION_MS
    // Per-cell threshold: add two layers of noise (frequency x2 at strength x0.5), then stretch the result to the range 0 to (1 - glow width).
    // So at progress 1 even the last cell has passed the glow band and the window is left clean
    function buildThresholds() {
        const rng = makeRng(4735);
        const lw = Math.ceil(PANEL.w * NOISE_SCALE * 2) + 3;
        const lh = Math.ceil(PANEL.h * NOISE_SCALE * 2) + 3;
        const lattice = [];
        for (let i = 0; i < lw * lh; i += 1) {
            lattice.push(rng());
        }
        const raw = [];
        let lo = 1e9;
        let hi = -1e9;
        for (let r = 0; r < DISSOLVE_ROWS; r += 1) {
            for (let c = 0; c < DISSOLVE_COLS; c += 1) {
                const px = (c + 0.5) * DISSOLVE_CELL_PX;
                const py = (r + 0.5) * DISSOLVE_CELL_PX;
                const v = valueNoise(lattice, lw, px * NOISE_SCALE, py * NOISE_SCALE)
                    + 0.5 * valueNoise(lattice, lw, px * NOISE_SCALE * 2, py * NOISE_SCALE * 2);
                raw.push(v);
                lo = Math.min(lo, v);
                hi = Math.max(hi, v);
            }
        }
        return raw.map(function (v) { return ((v - lo) / Math.max(hi - lo, 1e-6)) * (1 - DISSOLVE_EDGE); });
    }

06A Modal That Clears as Its Ripple Settles

A 68×43 height grid of 4 px cells runs a wave equation every 16 ms, starting with one drop in the middle and two smaller drops, and each cell redraws the window image shifted by its height slope times 0.08, which moves pixels by up to 15.0 px. The wobble fades until the energy drops under RIPPLE_CALM of 6 at 1,504 ms, a slow, watery entrance for a summer feature announcement. In the render: 75.9% area, 21/23 frames, mean intensity 34.4.

A Modal That Clears as Its Ripple Settles
Open in a new tab

06 · A Modal That Clears as Its Ripple Settles — Measured area 75.897% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

RIPPLE_DAMPINGRIPPLE_STRENGTHREFRACT_SCALE
    // One wave-equation step: next height = (sum of the four neighbors / 2 - previous height) x damping. Edge cells are pinned to 0 so the ripple bounces back off the walls
    function waveStep() {
        const c = waveCur;
        const p = wavePrev;
        for (let y = 1; y < RIPPLE_ROWS - 1; y += 1) {
            for (let x = 1; x < RIPPLE_COLS - 1; x += 1) {
                const i = y * RIPPLE_COLS + x;
                p[i] = ((c[i - 1] + c[i + 1] + c[i - RIPPLE_COLS] + c[i + RIPPLE_COLS]) / 2 - p[i]) * RIPPLE_DAMPING;
            }
        }
        waveCur = p;
        wavePrev = c;
    }

07A Close That Funnels Row by Row Into One Point

When the add-to-cart window closes, its picture is cut into 30 rows, each row's start is delayed by its distance to the cart pill at the top, and every row narrows first and only then slides and flattens into the point. With 900 ms the top row, nearest the cart, lands at 405 ms while the bottom row only sets off at 495 ms, and when the window reaches the cart the count goes up by one and the pill swells on a spring. Measured: 75.9% cumulative area, 20/23 moving frames, mean intensity 41.4.

A Close That Funnels Row by Row Into One Point
Open in a new tab

07 · A Close That Funnels Row by Row Into One Point — Measured area 75.871% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

FUNNEL_ROWSFUNNEL_TARGET_XFUNNEL_DURATION_MS
    // Progress of one row: rows farther from the target start later (a delay proportional to distance) and catch up faster in the time left
    function rowProgress(p, dn) { return clamp((p - FUNNEL_LAG * dn) / (1 - FUNNEL_LAG), 0, 1); }
    function itemPage() {
        drawOpener(1);
        const s = 1 + 0.4 * cartBump.x;
        ctx.save();
        ctx.translate(CART.x + CART.w / 2, CART.y + CART.h / 2);
        ctx.scale(s, s);
        fillRound(-CART.w / 2, -CART.h / 2, CART.w, CART.h, CART.h / 2, CART_FILL, 1);
        ctx.fillStyle = rgba(INK, 1);
        ctx.beginPath();
        ctx.arc(-CART.w / 2 + 12, 0, 4, 0, TAU);
        ctx.fill();
        ctx.font = fontOf(700, 12);
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText(String(cartCount), 6, 0.5);
        ctx.restore();
    }
        // Draw the far rows first so the rows nearest the target end up on top
        const order = [];
        for (let i = 0; i < FUNNEL_ROWS; i += 1) {
            order.push(i);
        }
        order.sort(function (a, b) { return dist[b] - dist[a]; });
        for (let n = 0; n < order.length; n += 1) {
            const i = order[n];
            const dn = (dist[i] - dmin) / Math.max(dmax - dmin, 1);
            const q = rowProgress(p, dn);
            // Narrow the width fast (1.6x faster) and move the position late (squared): each row thins first, then gets pulled in
            const wq = smooth(q * 1.6);
            const mq = q * q;
            const w = lerp(PANEL.w, 3, wq);
            const cx = lerp(cx0, pointX, mq);
            const cy = lerp(PANEL.y + (i + 0.5) * rh, pointY, mq);
            const h = lerp(rh, rh * 0.2, mq);
            ctx.globalAlpha = 1 - smooth((q - 0.85) / 0.15);
            blit(spriteFull, 0, i * rh, PANEL.w, rh, cx - w / 2, cy - h / 2, w, h + 0.4);
        }
        ctx.globalAlpha = 1;
    }

08A Close That Breaks Into Falling Glass Shards

A 7×5 grid of points over the window is jittered by up to ±35% of a cell with seed 4738 and each cell is split along a diagonal, giving 48 triangles that fly outward from the center of the "Delete" button at 158.6 to 237 px/s, spin at up to 5.8 rad/s, and fall under 1,400 px/s² of gravity. After a 120 ms crack and a 900 ms fall all 48 are below the stage by 1,024 ms, while "Cancel", Esc, and the backdrop close the window with a plain spring shrink and no breaking. The render recorded 75.9% area, 17/23 frames and a mean intensity of 45.8.

A Close That Breaks Into Falling Glass Shards
Open in a new tab

08 · A Close That Breaks Into Falling Glass Shards — Measured area 75.914% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

SHARD_COUNTSHARD_GRAVITYSHARD_SPIN_MAX
    // Split into shards: lay (cols+1)x(rows+1) points over the window, jitter them with a seeded random number, then cut each cell along one diagonal into two triangles.
    // The initial speed points from the break center (bx, by) toward the centroid (outward), and is faster the closer the shard is
    function buildShards(bx, by) {
        const rng = makeRng(4738);
        const cw = PANEL.w / SHARD_COLS;
        const ch = PANEL.h / SHARD_ROWS;
        const px = [];
        const py = [];
        for (let r = 0; r <= SHARD_ROWS; r += 1) {
            for (let c = 0; c <= SHARD_COLS; c += 1) {
                let x = c * cw;
                let y = r * ch;
                if (c > 0 && c < SHARD_COLS) {
                    x += (rng() - 0.5) * cw * 0.7;
                }
                if (r > 0 && r < SHARD_ROWS) {
                    y += (rng() - 0.5) * ch * 0.7;
                }
                px.push(x);
                py.push(y);
            }
        }
        const idx = function (r, c) { return r * (SHARD_COLS + 1) + c; };
        const tris = [];

09A Checkout Confirm Modal That Opens, Confirms and Returns to Its Button

The order window opens on a spring with a damping ratio of 0.62, and from the moment "Confirm" is pressed the button sinks to 0.924 on a press spring and springs back while it morphs into a circle with a check mark over 384 ms, holds for about 300 ms, and then the whole window shrinks back into the opener, finishing 1,136 ms after the press. The button's size factor is k = 1 + press.x * PRESS_DEPTH, with the press spring kicked at a speed of −10, so one value controls how deep the press looks. Measured: 75.9% cumulative area, 19/23 moving frames, mean intensity 47.1.

A Checkout Confirm Modal That Opens, Confirms and Returns to Its Button
Open in a new tab

09 · A Checkout Confirm Modal That Opens, Confirms and Returns to Its Button — Measured area 75.903% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

CONFIRM_HOLD_MSCHECK_MORPH_MSLOOP_MS
    // Confirm button: the press spring sets its size, and the morph sets its width (down to a circle), color and check stroke. Window coordinates
    function drawConfirm() {
        const m = morph();
        const bx = CONFIRM_BOX.x + CONFIRM_BOX.w / 2;
        const by = CONFIRM_BOX.y + CONFIRM_BOX.h / 2;
        const k = 1 + press.x * PRESS_DEPTH;
        const w = lerp(CONFIRM_BOX.w, CONFIRM_BOX.h, m) * k;
        const h = CONFIRM_BOX.h * k;
        roundRectPath(ctx, bx - w / 2, by - h / 2, w, h, lerp(BUTTON_RADIUS, h / 2, m), lerp(BUTTON_RADIUS, h / 2, m));
        ctx.fillStyle = rgba(mix(ACCENT, SUCCESS, m), 1);
        ctx.fill();
        const la = 1 - smooth(m / 0.4);
        if (la > 0.001) {
            ctx.save();
            ctx.globalAlpha *= la;
            ctx.translate(bx, by);
            ctx.scale(k, k);
            fitFont(ctx, CONFIRM_LABEL, 600, 12.5, CONFIRM_BOX.w - 14);
            ctx.fillStyle = rgba(INK, 1);
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.fillText(CONFIRM_LABEL, 0, 0.5);
            ctx.restore();
        }
        // Check stroke: give the two-segment polyline a dash gap as long as the stroke, then shrink that gap so it draws in from the start
        const ck = smooth((m - 0.35) / 0.65);
        if (ck > 0.001) {
            const len = Math.hypot(5, 5) + Math.hypot(10, 11);
            ctx.save();
            ctx.translate(bx, by);
            ctx.strokeStyle = rgba(INK, 1);
            ctx.lineWidth = 3;
            ctx.lineCap = 'round';
            ctx.lineJoin = 'round';
            ctx.setLineDash([len, len]);
            ctx.lineDashOffset = len * (1 - ck);
            ctx.beginPath();
            ctx.moveTo(-7, 0);
            ctx.lineTo(-2, 5);
            ctx.lineTo(8, -6);
            ctx.stroke();
            ctx.restore();
        }
    }

Why Can't a Modal That Only Fades and Scales Show Where It Came From?

A window that only changes opacity and scale grows from its own center. Even if the button that opened it sits in the bottom right corner, the window swells out of the middle of the screen and shrinks back into it, so the motion gives no hint of what was pressed. This stage is set up the same way: the opener sits bottom right with its center at (396, 265) and the window sits in the middle at (104, 56), 272×172. That is why 02 grows the window out of the button's rectangle, 03 spreads its circle from the button's center, 07 pulls the window into the cart or back into the opener, and 09 returns the close to the opener's spot. Once the start and end of the motion are tied to something real on the screen, the eye follows where the window came from and where it went without any label. When a keyframed fade and scale is all you need, the modal transition CSS in 9 CSS Modal Animation Patterns covers it, and this post adds the math that remembers a starting point on top of that.

Interpolating From the Trigger's Rectangle, and Sizing the Circle to the Farthest Corner

How 02 stretches a button into a window and scales the text back

growRect mixes the opener's rectangle and the window's rectangle through an ease-in-out cubic value e, and the ratio of the current rectangle to the final window becomes sx and sy. The parent layer stretches by that ratio, and the child layer that paints the text divides it back out, so the inverse grows sharply when the rectangle is small.

Time Curve value e Scale sx × sy Inverse for the text
0% 0 0.471 × 0.198 2.125 × 5.059
50% 0.5 0.735 × 0.599 1.36 × 1.67
75% 0.938 0.967 × 0.950 1.034 × 1.053

At the first instant the text has to be scaled back about five times vertically to look normal, because the 34 px tall button is being stretched toward a 172 px tall window. The rounded corners get the same correction: the radius is passed in as rad / sx and rad / sy, so after the stretch the corners still read as circular arcs instead of flattened ovals.

Why 03 leaves a corner uncovered unless it measures the farthest one

irisMaxRadius takes the distance from the start point to all four stage corners and keeps the largest. Had the radius stopped at the nearest corner, 91 px, the circle would end with the other three corners still showing the page behind it.

Time 10% 25% 50% 75% 100%
Radius 129.1 px 275.5 px 416.9 px 469 px 476.5 px

With the ease-out cubic curve, the radius is already at 87.5% when half the time has passed, and the second half is spent filling in the last 59.6 px slowly. On the way out the same curve is walked backward, so the circle speeds up as it closes. For more ways to uncover a picture with circles and bands, see the canvas image reveal wipes.

A Window Taller Than the Screen, Opening Mid-Scroll, Reopening While Closing — Where Modal Transitions Break

Reopening halfway through a close

Each item takes a different road back. 02, 03, 04, and 05 turn their progress around from wherever it currently stands, while 01 and 09 only change the spring's target, so the window keeps its velocity and changes direction. 07 comes back out of the point it was being pulled into, 08 restarts its spring from zero if it was already breaking, and 06 drops one more drop into the water.

When the window is taller than the screen

The window has a fixed size of 272×172 in scene coordinates, and on a narrow screen the whole stage shrinks while keeping aspect-ratio: 480 / 300. In this demo the window cannot outgrow the screen, and it also has no scroll area of its own and no lock on the page behind it. A production modal with long content likely needs both: a scrollable region inside the window and an overflow: hidden lock on the page.

Opening in the middle of a scroll

Opening moves focus to a button inside the window and closing moves it back to the opener, and all three .focus() calls pass { preventScroll: true }. Moving focus therefore does not make the page jump to the element, which matters most when someone opens a window halfway down a long page.

The picture and the click target drift apart while moving

The transparent DOM layer is pinned to the fully open position. While 02 is still growing from the button size or 08's shards are still falling, the painted window and the clickable area do not line up, and a click during the opening lands according to the final layout. For that reason the focus ring is hidden until the window gets .md--open, since the ring is drawn around the final position.

New text, old picture

The canvas reads the DOM text once, paints the window picture and keeps reusing it. Change the title or the body while the page runs and the picture keeps the old words until fitCanvas repaints it on the next resize. The locale text is swapped in before load, so it causes no trouble here.

Ripples and pixels mean many draw calls

06 calls drawImage 2,924 times per frame and adds up to as many shading fills, 05 draws up to 1,334 cells, 04 paints the picture and a shade for each of its 87 strips, and 08 clips 48 shards one by one. The canvas follows the device pixel ratio up to 2, so even on a screen with a ratio of 3 the pixel count stays at four times the scene size. Frame times were not measured per device, so testing on the slowest phone you support is the reliable check.

Every folder in the archive holds a vanilla index.html built from the same source as its React version, and when the unzip tool asks for the password, type hp73ywt5 and you can swap in your own text right away.

How Spring Damping, Dissolve Cell Size and Funnel Time Change the Open and Close

Name Value Feel
Lower the spring damping 01 SPRING_DAMPING 14 → 8 At 14 the damping ratio is 0.57, the window swells once to 104.1% and holds within 1% from 480 ms. At 8 the ratio drops to 0.33, it swells to 114.7%, rises above the target three times and only settles within 1% at 1,088 ms, with the lift deepening to 34.5 px and easing back each time.
Enlarge the dissolve cells 05 DISSOLVE_CELL_PX 6 → 16 At 6 px there are 46×29 = 1,334 cells that gather like fine sand. At 16 px there are 17×11 = 187, so chunky mosaic tiles pop in, and the share of cells shown at 50% progress drops from 66.5% to 58.3%.
Shorten the funnel time 07 FUNNEL_DURATION_MS 900 → 500 At 900 the top row reaches the point at 405 ms and the bottom row starts at 495 ms, so the funnel shape is easy to see. At 500 the same ratios pull in to 225 ms and 275 ms and everything is in by 512 ms, which squeezes the speed gap between rows and shows less of the bend.

Beyond the three values in the table, moving 03's IRIS_ORIGIN_X and IRIS_ORIGIN_Y makes irisMaxRadius measure the four corners again from the new center, so there is no radius to fix by hand. In 08, SHARD_COUNT 48 and SHARD_ROWS 4 are written as literals, and SHARD_COLS is derived from them as SHARD_COUNT / (2 * SHARD_ROWS), which gives 6. To change the shard count, set SHARD_COUNT and SHARD_ROWS together and keep SHARD_COUNT a multiple of twice SHARD_ROWS so the column count stays a whole number.

Announcing the Window as a Dialog — Dialog Role and Reduced-Motion Accessibility

In all nine, the <div class="md__dialog"> in the transparent DOM layer has role="dialog" and aria-modal="true", points aria-labelledby at the title <h2> and aria-describedby at the body <p>, and stays hidden while the window is closed. The opener carries aria-haspopup="dialog", and the roles are described in the dialog element reference and aria-modal.

Keyboard path in, out and back to the opener

Esc closes the window only while it is shown, and a press on the backdrop outside it closes it too. When the window opens, focus goes to the confirm button, except in 08, where the action cannot be undone and focus lands on "Cancel". While the window is up the opener gets inert, and Tab moves only between the two buttons inside.

    function requestClose(user, via) {
        if (!shown()) {
            return;
        }
        const hadFocus = dialog.contains(document.activeElement);
        showDialog(false);
        setPhase('closing');
        itemClose(via);
        if (still) {
            snapTo(false);
        }
        // On close, focus goes back to the button that opened the window
        if (user || hadFocus) {
            openBtn.focus({ preventScroll: true });
        }
    }

Focus moves only when a person closed the window or when focus was already inside it, so the automatic demo loop does not pull focus away from wherever you were. The focus ring is drawn by focus-visible rules and hidden while the window is moving. The status line starts as aria-live="off" and switches to polite the first time someone presses or types a key, and in 06 it reads "The ripple is settling" only while the window is opening and its ripple layer is at full opacity (rippleAlpha === 1).

Under reduced motion the window opens and closes in place

When prefers-reduced-motion is set to reduce, no requestAnimationFrame loop runs. The first screen is one fully open window, and opening or closing jumps straight to the end state through snapTo. If the setting is switched on while the page is open, the window snaps to the end of whichever direction it was heading, and if it is switched off again, startLoop() starts the loop back up. In 09 the "Confirm" button skips the check animation under reduce and closes the window at once.

Questions to Ask Before Putting Popup Animation CSS on a Real Modal

Do these modal animation examples have to be drawn on canvas in a real product?

No, and in most cases the same math works better on DOM text. This demo paints even the text on canvas so it can slice and move the window, which is why the picture and the click area drift apart during motion and a text change needs a repaint. The springs and rectangle interpolation in 01, 02, and 09 move over to transform, the circle in 03 becomes a computed radius inside clip-path circle(), and only the effects that cut the window into cells, rows or shards, 05 through 08, still call for a canvas. For transitions between pages, the View Transitions API patterns fit better, and for a window that enlarges a photo, see the CSS lightbox gallery transitions.

Can 09 go into a real order confirmation as it is?

The check mark in the demo follows a fixed timeline after the press, and no order request is sent anywhere. In a real checkout it is likely safer to show only the press spring right away and start the check morph and the shrink once the server has confirmed the order, so the check mark does not promise something that has not happened yet.

If a close effect runs long, does Esc have to wait for it?

It does not. The moment Esc is pressed the DOM dialog becomes hidden and focus returns to the opener, and only the painted close keeps playing behind it. In 09, Esc or "Close" works even while the check mark is drawing and moves straight on to the shrink.

Enter the archive password

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