Layout changes without losing the active card

How-to Last tested 24 October 2026

Put the new layout into the document first, then animate each item from its previous rectangle toward its new rectangle. Keep item identity and focus separate from that temporary transform, so changing size or order does not require replacing the controls the reader is using.

Give the layout a stable destination

The example has three original cards: Atlas, Beacon and Cove. A layout control switches between two columns and one column. A shared action moves the first card to the end, while each card has its own move action. Those changes update real document order and container rules; the animation only describes the journey between the two arrangements.

Keep that distinction when adapting the example. The final order must remain useful if motion is skipped or interrupted. Do not use translated pixels as the only record of where a card belongs. Here, turning off every animation still leaves the cards in the selected layout, with their headings and buttons intact.

Each card retains the same DOM node. The code moves that node instead of creating a replacement with the same text. Its button keeps its ID and event route. A delegated handler on the list finds the card containing the activated button and appends that card to the list.

Measure before and after the change

getBoundingClientRect() returns the element’s border-box geometry relative to the viewport.1 Read all three starting rectangles before changing the layout. After the mutation, read all three destination rectangles. Keeping the two passes distinct makes it clear which coordinates belong to which arrangement.

For each card, subtract the destination’s left and top from the starting left and top. Divide the starting width and height by the corresponding destination dimensions. Those four values supply a translation and a scale that temporarily place the destination box over its starting box.

The example assumes three visible, nonzero-size cards. It does not include hidden records, virtualized rows or elements removed during the mutation. An application with those cases needs a rule for missing rectangles instead of dividing by a zero width or guessing an offscreen starting point.

Choose the transform origin deliberately

The cards use transform-origin: 0 0, making their upper-left corner the reference point for scaling.2 The initial keyframe lists translation followed by scale. Transform order matters: applying the same functions in another order can produce another position.3 Keep the arithmetic and the transform list together when changing either.

Scaling the complete card also scales its text and border during the transition. That is a tradeoff of this compact example. If an interface cannot accept that treatment, keep the size change immediate and animate only position, or give an inner content layer a separately designed treatment. Do not silently add an inverse transform without accounting for its own origin and lifetime.

The destination keyframe is transform: none. It hands presentation back to ordinary layout instead of leaving a permanent translated coordinate behind. The Web Animations API creates the temporary animation; its configured duration here is 500 milliseconds.4 That setting describes the requested effect, not a measured frame rate.

The complete example

Save this original MIT-licensed HTML as a local file. It contains the cards, both reorder routes, the layout control and an explicit Stop action. The same component is included in the demo, alongside its procedure.

<!doctype html>
<!-- SPDX-License-Identifier: MIT -->
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Resize and reorder a card layout</title>
  <style>
    #cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; width: min(32rem, 100%); padding: 0; list-style: none; }
    #cards.narrow { width: min(18rem, 100%); grid-template-columns: 1fr; }
    #cards li { padding: 1rem; border: 2px solid #345; transform-origin: 0 0; }
    #cards button { min-height: 2.5rem; }
    :focus-visible { outline: 3px solid #045ac2; outline-offset: 3px; }
  </style>
</head>
<body>
<button id="size" aria-pressed="false">Narrow layout</button>
<button id="rotate">Move first card to end</button>
<button id="stop">Stop motion</button>
<ul id="cards">
  <li id="a">
    <h2>Atlas</h2>
    <button id="move-a">Move Atlas to end</button>
  </li>
  <li id="b">
    <h2>Beacon</h2>
    <button id="move-b">Move Beacon to end</button>
  </li>
  <li id="c">
    <h2>Cove</h2>
    <button id="move-c">Move Cove to end</button>
  </li>
</ul>
<script>
  const cards = document.querySelector('#cards');
  const size = document.querySelector('#size');
  const reduced = matchMedia('(prefers-reduced-motion: reduce)');
  let animations = [];
  function stop() {
    animations.forEach(animation => animation.cancel());
    animations = [];
  }
  function changeLayout(mutate) {
    const nodes = [...cards.children];
    const before = new Map(nodes.map(node => [node, node.getBoundingClientRect()]));
    const focus = document.activeElement;
    stop();
    mutate();
    if (focus?.isConnected) focus.focus({ preventScroll: true });
    const after = new Map(nodes.map(node => [node, node.getBoundingClientRect()]));
    if (reduced.matches || !Element.prototype.animate) return;
    animations = nodes.map(node => {
      const first = before.get(node), last = after.get(node);
      const x = first.left - last.left, y = first.top - last.top;
      const sx = first.width / last.width, sy = first.height / last.height;
      return node.animate([
        { transform: `translate(${x}px, ${y}px) scale(${sx}, ${sy})` },
        { transform: 'none' }
      ], { duration: 500, easing: 'ease-out' });
    });
  }
  size.addEventListener('click', () => changeLayout(() => {
    size.setAttribute('aria-pressed', cards.classList.toggle('narrow'));
  }));
  document.querySelector('#rotate').addEventListener('click', () => {
    changeLayout(() => cards.append(cards.firstElementChild));
  });
  cards.addEventListener('click', event => {
    const button = event.target.closest('button');
    if (button) changeLayout(() => cards.append(button.closest('li')));
  });
  document.querySelector('#stop').addEventListener('click', stop);
  document.addEventListener('keydown', event => {
    if (event.key === 'Escape') stop();
  });
  reduced.addEventListener('change', stop);
</script>
</body>
</html>

Walk through changes and interruptions

MDN describes cancellation as removing an animation’s effects and aborting its playback.5 This component does not read a finished promise or treat cancellation as successful completion. Code that saves a task after an animation finishes needs a separate rule for an interrupted request.

Keep resizing and motion separate

Both container widths are capped by available width, so the selected layout still has a normal sizing rule. The button changes a specific layout state; it does not intercept every browser resize. If the surrounding viewport changes independently, ordinary CSS handles the result without starting another animation pass.

That boundary avoids making every geometry notification a command to animate. A larger application may need resize-driven transitions, but it must decide which changes matter and when a new measurement should replace an old one. Reading a rectangle alone does not create that scheduling policy.

The reduced-motion branch still applies the requested order and width, but skips creating animations. A preference-change listener cancels any current effects. Keep that branch close to the point where animations are created; the content update should not depend on permission to animate.

For expanding content with an unknown final height, use the intrinsic-size guide. That is a different sizing problem from moving these existing boxes between known layout states.

Updating an existing layout integration

Keep the application’s actual sizing, ordering and focus requirements when replacing an earlier layout system. This route supplies new code and explanations. It does not reproduce earlier documentation or promise compatibility with a former framework’s objects or callbacks.

Not covered on this page

Sources

  1. MDN, Element.getBoundingClientRect(). Living documentation, opened 24 October 2026. ↩

  2. MDN, transform-origin. Living documentation, opened 24 October 2026. ↩

  3. MDN, transform. Living documentation, opened 24 October 2026. ↩

  4. MDN, Element.animate(). Living documentation, opened 24 October 2026. ↩

  5. MDN, Animation.cancel(). Living documentation, opened 24 October 2026. ↩

Code on this page is original and MIT licensed.

How we test: methods. Found a mistake? Corrections.