Skip to content

Transition Group

<quiet-transition-group> stable since 1.0

Transition groups improve the user's experience by adding subtle animations as items are added, removed, and reordered in the group.

Wrap a collection of elements in a transition group and use normal DOM APIs to add, remove, and reorder them. The transition group will automatically apply the appropriate animations as elements enter and exit the group.

1
2
3
4
5
6
7
8
Add random Remove random Shuffle Disable transitions
<div id="transition-group__boxes">
  <!-- Add your elements here -->
  <quiet-transition-group>
    <div class="box">1</div>
    <div class="box">2</div>
    <div class="box">3</div>
    <div class="box">4</div>
    <div class="box">5</div>
    <div class="box">6</div>
    <div class="box">7</div>
    <div class="box">8</div>
  </quiet-transition-group>

  <div class="buttons">
    <quiet-button data-action="add">Add random</quiet-button>
    <quiet-button data-action="remove">Remove random</quiet-button>
    <quiet-button data-action="shuffle">Shuffle</quiet-button>
    <quiet-button data-action="disable" toggle="off">Disable transitions</quiet-button>
  </div>
</div>

<!-- Everything below is for the demo -->
<script>
  const container = document.getElementById('transition-group__boxes');
  const transitionGroup = container.querySelector('quiet-transition-group');
  const shuffleButton = container.querySelector('quiet-button[data-action="shuffle"]');
  const addButton = container.querySelector('quiet-button[data-action="add"]');
  const removeButton = container.querySelector('quiet-button[data-action="remove"]');
  const disableButton = container.querySelector('quiet-button[data-action="disable"]');
  let count = transitionGroup.children.length;

  function addRandomBox() {
    if (transitionGroup.isTransitioning) return;
    const children = [...transitionGroup.children];
    const randomSibling = children[Math.floor(Math.random() * children.length)];
    const box = document.createElement('div');

    box.classList.add('box');
    box.textContent = String(++count);

    if (randomSibling) {
      randomSibling.before(box);
    } else {
      transitionGroup.append(box)
    }
  }

  function removeRandomBox() {
    if (transitionGroup.isTransitioning) return;
    const boxes = [...transitionGroup.children];
    if (boxes.length > 0) {
      const randomIndex = Math.floor(Math.random() * boxes.length);
      boxes[randomIndex].remove();
    }
  }

  function shuffleBoxes() {
    if (transitionGroup.isTransitioning) return;
    const boxes = [...transitionGroup.children];
    boxes.sort(() => Math.random() - 0.5);
    boxes.forEach(box => transitionGroup.append(box));
  }

  // Handle button clicks
  addButton.addEventListener('click', addRandomBox);
  removeButton.addEventListener('click', removeRandomBox);
  shuffleButton.addEventListener('click', shuffleBoxes);
  disableButton.addEventListener('click', () => {
    transitionGroup.disableTransitions = disableButton.toggle !== 'off';
  });
</script>

<style>
  #transition-group__boxes {
    quiet-transition-group {
      display: flex;
      gap: 1rem;
      flex-wrap: wrap;
      flex-direction: row;
      margin-block-end: 2rem;
    }

    .buttons {
      display: flex;
      gap: .5rem;
      flex-wrap: wrap;
    }

    .box {
      display: flex;
      align-items: center;
      justify-content: center;
      width: 100px;
      height: 100px;
      font-size: 1.5rem;
      font-weight: var(--quiet-font-weight-semibold);
      background: var(--quiet-primary-fill-mid);
      border-radius: var(--quiet-border-radius);
      color: var(--quiet-primary-text-on-mid);

      @media screen and (max-width: 959px) {
        font-size: 1.25rem;
        width: 80px;
        height: 80px;  
      }
    }
  }
</style>

This example includes logic and styles for the demo, but the minimal markup you need for a transition group is shown below. Note that only direct children of the transition group will be animated.

<quiet-transition-group>
  <div class="item">...</div>
  <div class="item">...</div>
  <div class="item">...</div>
</quiet-transition-group>

For best results, avoid applying transitions, animations, and inline styles to transition group items, as they may interfere with the component's animations. Also avoid modifying the DOM during a transition to prevent interruptions.

Transition groups honor the user's prefers-reduced-motion setting. If you're not seeing animations, this might be why. To override this behavior, which is generally not recommended, use the ignore-reduced-motion attribute.

Examples Jump to heading

Working with elements Jump to heading

Use standard DOM APIs to add, remove, and reorder elements and the transition group will automatically animate your changes. This example demonstrates how adding, removing, and reordering list items will be handled by the transition group.

Shuffle
<div id="transition-group__list">
  <!-- A template for list items -->
  <template>
    <div class="list-item">
      <span class="label"></span>
      <quiet-button-group label="Sort">
        <quiet-button size="sm" data-action="up" icon-label="Move up"><quiet-icon name="arrow-up"></quiet-icon></quiet-button>
        <quiet-button size="sm" data-action="down" icon-label="Move down"><quiet-icon name="arrow-down"></quiet-icon></quiet-button>
      </quiet-button-group>
      <quiet-button size="sm" variant="text" data-action="delete" icon-label="Delete"><quiet-icon name="trash"></quiet-icon></quiet-button>
    </div>
  </template>

  <quiet-transition-group>
    <!-- Items are generated using the <template> above -->
  </quiet-transition-group>

  <form>
    <quiet-text-field placeholder="New item" appearance="filled" pill></quiet-text-field>
    <quiet-button type="submit" variant="primary" icon-label="Add to list" pill><quiet-icon name="plus"></quiet-icon></quiet-button>
    <quiet-divider orientation="vertical"></quiet-divider>
    <quiet-button pill data-action="shuffle">Shuffle</quiet-button>
  </form>
</div>

<script>
  const container = document.getElementById('transition-group__list');
  const transitionGroup = container.querySelector('quiet-transition-group');
  const template = container.querySelector('template');
  const addForm = container.querySelector('form');
  const shuffleButton = container.querySelector('[data-action="shuffle"]');
  let count = transitionGroup.children.length;

  function createListItem(label = '') {
    const listItem = template.content.cloneNode(true);
    listItem.querySelector('.label').textContent = label;
    return listItem;
  }

  // Create the initial list
  if (transitionGroup.children.length === 0) {
    ['Feed the cats', 'Change the litter box', 'Buy catnip', 'Playtime'].forEach(label => {
      const listItem = createListItem(label);
      transitionGroup.append(listItem);
      updateListItems();
    });
  }

  // Update the list now that the content has changed
  transitionGroup.addEventListener('quiet-content-changed', updateListItems);

  // Add an item
  addForm.addEventListener('submit', event => {
    const textField = addForm.querySelector('quiet-text-field');
    const label = textField.value;
    const listItem = createListItem(label || '(Untitled)');
    transitionGroup.append(listItem);
    textField.value = '';    

    event.preventDefault();
  });

  // Shuffle items
  shuffleButton.addEventListener('click', () => {
    const items = [...transitionGroup.children];
    items.sort(() => Math.random() - 0.5);
    items.forEach(item => transitionGroup.append(item));
  });

  // Handle item actions
  container.addEventListener('click', event => {
    const button = event.target.closest('[data-action]');
    if (!button) return;
    const action = button.getAttribute('data-action');
    const listItem = button.closest('.list-item');

    // Delete an item
    if (action === 'delete') {
      listItem.remove();
    }

    // Move an item up
    if (action === 'up' && listItem.previousElementSibling) {
      listItem.previousElementSibling.before(listItem);
    }

    // Move an item down 
    if (action === 'down' && listItem.nextElementSibling) {
      listItem.nextElementSibling.after(listItem);
    }
  });

  // Make sure the first/last items have the up/down buttons disabled
  function updateListItems() {
    const listItems = transitionGroup.querySelectorAll('.list-item');

    listItems.forEach((listItem, index) => {
      listItem.querySelector('[data-action="up"]').disabled = index === 0;
      listItem.querySelector('[data-action="down"]').disabled = index === listItems.length - 1;
    });
  }
</script>

<style>
  #transition-group__list {
    quiet-transition-group {
      display: flex; 
      flex-direction: column; 
      gap: .5rem; 
      margin-block-end: 1rem;
    }

    form {
      display: flex;
      gap: .5rem;
      align-items: center;

      quiet-text-field {
        flex: 1 1 auto;
      }

      quiet-button {
        flex: 0 0 auto;
      }

      quiet-divider {
        height: 2rem;
      }

      quiet-text-field::part(label),
      quiet-text-field::part(description) {
        display: none;
      }
    }

    .list-item {
      display: flex;
      gap: .5rem;
      align-items: center;
      justify-content: start;
      width: 100%;
      background-color: var(--quiet-paper-color);
      border: var(--quiet-border-style) var(--quiet-border-width) var(--quiet-neutral-stroke-softer);
      border-radius: var(--quiet-border-radius);
      box-shadow: var(--quiet-shadow-softer);
      color: var(--quiet-neutral-text-on-soft);
      padding: .5rem 1rem;

      .label {
        flex: 1 1 auto;
      }

      quiet-button {
        flex: 0 0 auto;
      }

      quiet-button[data-action="delete"] quiet-icon {
        color: var(--quiet-destructive-text-colorful);
      }
    }      
  }
</style>

As elements are added, removed, and reordered, their positions in the DOM change instantly. The elements are then animated from their old positions to their new positions, meaning CSS selectors such as :first-child, :last-child, :nth-child(), et al will apply as soon as the transition starts, which may not always be desirable. To avoid this, use ids and classes in lieu of position-based selectors.

Awaiting transitions Jump to heading

Avoid making DOM changes while a transition is running. The transitionComplete property holds a promise that resolves when the current or next transition ends. You can await it to be sure all animations are complete before proceeding with further DOM changes.

Swap
<div id="transition-group__awaiting">
  <quiet-transition-group style="--duration: 1000ms;">
    <div class="box" style="background-color: deeppink;"></div>
    <div class="box" style="background-color: dodgerblue;"></div>
  </quiet-transition-group>

  <div class="controls">
    <quiet-button>Swap</quiet-button>
  </div>
</div>

<script>
  const container = document.getElementById('transition-group__awaiting');
  const transitionGroup = container.querySelector('quiet-transition-group');
  const swapButton = container.querySelector('quiet-button');

  swapButton.addEventListener('click', async () => {
    const box = transitionGroup.querySelector('.box');

    // Disable the button and swap the elements
    swapButton.disabled = true;
    transitionGroup.append(box);

    // Wait for the transition to complete and enable the button
    await transitionGroup.transitionComplete;
    swapButton.disabled = false;
  });
</script>

<style>
  #transition-group__awaiting {
    quiet-transition-group {
      flex-direction: row;
      gap: 1rem;
    }

    .box {
      width: 80px;
      height: 80px;
      border-radius: var(--quiet-border-radius);
    }

    .controls {
      margin-block-start: 2rem;
    }
  }
</style>

Changing the layout Jump to heading

Transition groups use a columnar flex layout, by default. To change the layout, apply flex-direction: row to the transition group. You can add spacing between items by setting the gap property.

quiet-transition-group {
  flex-direction: row;
  gap: 2rem;
}

For best results, avoid using complex layouts within transition groups.

Changing the duration Jump to heading

To change the animation speed, set the --duration custom property on the transition group. Each transition is made up of one or more steps (e.g. add, remove, reposition) and the duration applies to each individual step, not the total transition time.

<quiet-transition-group style="--duration: 1250ms;" id="transition-group__duration">
  <div class="box" style="background-color: deeppink;"></div>
  <div class="box" style="background-color: dodgerblue;"></div>
  <div class="box" style="background-color: rebeccapurple;"></div>
  <div class="box" style="background-color: tomato;"></div>
</quiet-transition-group>

<script>
  const transitionGroup = document.getElementById('transition-group__duration');

  function moveTheBox() {
    const lastChild = transitionGroup.lastElementChild;
    if (lastChild) {
      transitionGroup.prepend(lastChild);
    }
  }

  // Move the box again after the transition ends
  transitionGroup.addEventListener('quiet-transition-end', () => {
    setTimeout(moveTheBox, 1000);
  });

  // Ensure the element is registered
  Promise.all([
    customElements.whenDefined('quiet-transition-group'),
    transitionGroup.updateComplete
  ])
  .then(moveTheBox);
</script>

<style>
  #transition-group__duration {
    flex-direction: row;
    gap: 1rem;

    .box {
      width: 80px;
      height: 80px;
      border-radius: var(--quiet-border-radius);
    }
  }
</style>

Changing the animation Jump to heading

Transition groups use the Web Animations API to move elements around. To customize the enter and exit animations, pass a QuietTransitionAnimation object to the transition group's transitionAnimation property. A QuietTransitionAnimation includes keyframes and easings for entering and exiting animations. The interface looks like this:

interface QuietTransitionAnimation {
  enter: {
    keyframes: Keyframe[];
    easing: string;
  };

  exit: {
    keyframes: Keyframe[];
    easing: string;
  };
}

Here's an example of a custom animation that scales and fades elements in and out as they enter and leave.

const transitionGroup = document.querySelector('quiet-transition-group');

transitionGroup.transitionAnimation = {
  enter: {
    keyframes: [
      { opacity: 0, scale: 0.75 },
      { opacity: 1, scale: 1 }
    ],
    easing: 'cubic-bezier(0.33, 1.2, 0.66, 1)'
  },
  exit: {
    keyframes: [
      { opacity: 1, scale: 1 },
      { opacity: 0, scale: 0.75 }
    ],
    easing: 'cubic-bezier(0.33, 0, 0.67, 0.2)'
  }
};

Using Scurry animations Jump to heading

Quiet's Scurry module provides a number of ready-to-use, RTL-friendly animations that work great with transition groups. You can install Scurry locally using npm or import animations directly from the CDN.

If you're using npm, install Scurry using the following command.

npm i @quietui/scurry

Here you can preview the animations that are available in Scurry.

Copy CDN import Copy npm import

Import any of the transition animation functions as shown below. Animations are RTL-aware, so make sure to call each function with the dir parameter to get a QuietTransitionAnimation object with proper directionality. Then, apply it to the appropriate transition group's transitionAnimation property.

// Import an animation
import { tornado } from '@quietui/scurry';

// Get a reference to the transition group
const transitionGroup = document.querySelector('quiet-transition-group');

// Change the animation
transitionGroup.transitionAnimation = tornado({ dir: 'ltr' });

Disabling transitions Jump to heading

Add the disable-transitions attribute to disable transition animations. Note that the DOM will still be modified when this option is enabled.

<div id="transition-group__disabling">
  <quiet-transition-group>
    <div class="circle" style="background-color: deeppink;"></div>
    <div class="circle" style="background-color: dodgerblue;"></div>
    <div class="circle" style="background-color: rebeccapurple;"></div>
  </quiet-transition-group>

  <quiet-switch label="Disable transitions"></quiet-switch>
</div>

<script>
  const container = document.getElementById('transition-group__disabling');
  const transitionGroup = container.querySelector('quiet-transition-group');
  const disableSwitch = container.querySelector('quiet-switch');

  // Toggle transitions
  disableSwitch.addEventListener('quiet-change', () => {
    transitionGroup.disableTransitions = !transitionGroup.disableTransitions;
  });

  // Rotate the circles every second
  setInterval(() => {
    const firstChild = transitionGroup.firstElementChild;
    if (firstChild) {
      transitionGroup.append(firstChild);
    }
  }, 1000);
</script>

<style>
  #transition-group__disabling {
    text-align: center;

    quiet-transition-group {
      --duration: 500ms;
      --easing: ease-in-out;
      display: grid;
      grid-template-columns: 1fr 1fr;
      grid-template-rows: 1fr 1fr;
      gap: 60px;
      height: 220px;
      margin-block-end: 2rem;
    }

    .circle {
      width: 80px;
      height: 80px;
      border-radius: 50%;

      &:nth-child(1) {
        grid-column: 1 / -1;
        grid-row: 1;
        justify-self: center;
      }

      &:nth-child(2) {
        grid-column: 1;
        grid-row: 2;
        justify-self: end;
      }

      &:nth-child(3) {
        grid-column: 2;
        grid-row: 2;
        justify-self: start;  
      }      
    }
  }
</style>

API Jump to heading

Importing Jump to heading

The autoloader is the recommended way to import components but, if you prefer to do it manually, the following code snippets will be helpful.

CDN npm

To manually import <quiet-transition-group> from the CDN, use the following code.

import 'https://cdn.jsdelivr.net/npm/@quietui/quiet@1.0.0/dist/components/transition-group/transition-group.js';

To manually import <quiet-transition-group> from npm, use the following code.

import '@quietui/quiet/dist/components/transition-group/transition-group.js';

Slots Jump to heading

Transition Group supports the following slots. Learn more about using slots

Name Description
(default) One or more elements to transition when adding, removing, and reordering the DOM.

Properties Jump to heading

Transition Group has the following properties that can be set with corresponding attributes. In many cases, the attribute's name is the same as the property's name. If an attribute is different, it will be displayed after the property. Learn more about attributes and properties

Property / Attribute Description Reflects Type Default
isTransitioning Determines if the transition group is currently animating. (Property only) boolean false
transitionAnimation A custom animation to use for enter/exit transitions, Works well with animations from @quietui/scurry. (Property only) QuietTransitionAnimation
undefined
transitionComplete A promise that resolves when the current or next transition ends. This is a great way to ensure transitions have stopped before doing something else. (Property only) Promise<void>
disableTransitions
disable-transitions
Disables transition animations. However, the quiet-content-changed and quiet-transition-end events will still be dispatched. boolean false
ignoreReducedMotion
ignore-reduced-motion
By default, no animation will occur when the user indicates a preference for reduced motion. Use this attribute to override this behavior when necessary. boolean false

Methods Jump to heading

Transition Group supports the following methods. You can obtain a reference to the element and call them like functions in JavaScript. Learn more about methods

Name Description Arguments
updateElementPositions() Updates the cached coordinates of all child elements in the transition group. In most cases, you shouldn't have to call this method. However, if you're resizing or animating elements imperatively, you may need to call this immediately before appending or removing elements to ensure a smooth transition.

Events Jump to heading

Transition Group dispatches the following custom events. You can listen to them the same way was native events. Learn more about custom events

Name Description
quiet-content-changed Emitted when content changes and before the transition animation begins.
quiet-transition-end Emitted when transition animations end.

CSS custom properties Jump to heading

Transition Group supports the following CSS custom properties. You can style them like any other CSS property. Learn more about CSS custom properties

Name Description Default
--duration The duration of each individual step (not the total transition time). 0.25s

Custom States Jump to heading

Transition Group has the following custom states. You can target them with CSS using the selectors shown below. Learn more about custom states

Name Description CSS selector
transitioning Applied when a transition is active. :state(transitioning)
Search this website Toggle dark mode Get the code on GitHub Follow @quietui.org on Bluesky Follow @quiet_ui on X
    No results found