Skip to contents

Animation

A guide to animating Base UI components.

Base UI components can be animated using CSS transitions, CSS animations, or JavaScript animation libraries. Each component provides a number of data attributes to target its states, as well as a few attributes specifically for animation.

CSS transitions

Use the following Base UI attributes for creating transitions when a component becomes visible or hidden:

  • [data-starting-style] corresponds to the initial style to transition from.
  • [data-ending-style] corresponds to the final style to transition to.

Transitions are recommended over CSS animations, because a transition can be smoothly cancelled midway. For example, if the user closes a popup before it finishes opening, with CSS transitions it will smoothly animate to its closed state without any abrupt changes.

popover.css
.Popup {
  box-sizing: border-box;
  padding: 1rem 1.5rem;
  background-color: canvas;
  transform-origin: var(--transform-origin);
  transition:
    transform 150ms,
    opacity 150ms;

  &[data-starting-style],
  &[data-ending-style] {
    opacity: 0;
    transform: scale(0.9);
  }
}

CSS animations

Use the following Base UI attributes for creating CSS animations when a component becomes visible or hidden:

  • [data-open] corresponds to the style applied when a component becomes visible.
  • [data-closed] corresponds to the style applied before a component becomes hidden.
popover.css
@keyframes scaleIn {
  from {
    opacity: 0;
    transform: scale(0.9);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

@keyframes scaleOut {
  from {
    opacity: 1;
    transform: scale(1);
  }
  to {
    opacity: 0;
    transform: scale(0.9);
  }
}

.Popup[data-open] {
  animation: scaleIn 250ms ease-out;
}

.Popup[data-closed] {
  animation: scaleOut 250ms ease-in;
}

JavaScript animations

JavaScript animation libraries need the popup to stay rendered while its exit animation plays, and Base UI to know when that animation is over.

When a popup closes, open becomes false right away, but the popup stays rendered in a closing phase until its closing animation finishes. Base UI detects animations on the popup element, including animations that include opacity. Once the animation ends, the popup is removed from the DOM, or hidden if the <Portal> has keepMounted, and onOpenChangeComplete(false) fires.

The demos below use the Web Animations API, driven by the reactive open state supplied to the render callback. The same approach works with any JavaScript animation library that can animate a DOM element, such as Motion’s animate() function.

Animating components unmounted from DOM when closed

import { createSignal } from 'solid-js';
import { Popover } from 'base-ui-solid/popover';
import styles from './index.module.css';
import { AnimatedPopup } from './animated-popup';

export default function AnimatedPopoverDemo() {
  const [open, setOpen] = createSignal(false);
  return (
    <Popover.Root open={open()} onOpenChange={setOpen}>
      <Popover.Trigger class={styles.Trigger}>Trigger</Popover.Trigger>
      <Popover.Portal>
        <Popover.Positioner class={styles.Positioner} sideOffset={8}>
          <Popover.Popup
            class={styles.Popup}
            render={(props, state) => <AnimatedPopup {...props} open={state.open} />}
          >
            Popup
          </Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </Popover.Root>
  );
}

The popup is removed after its closing animation finishes.

Animating components kept in DOM when closed

import { createSignal } from 'solid-js';
import { Popover } from 'base-ui-solid/popover';
import styles from './index.module.css';
import { AnimatedPopup } from './animated-popup';

export default function AnimatedPopoverDemo() {
  const [open, setOpen] = createSignal(false);
  return (
    <Popover.Root open={open()} onOpenChange={setOpen}>
      <Popover.Trigger class={styles.Trigger}>Trigger</Popover.Trigger>
      <Popover.Portal keepMounted>
        <Popover.Positioner class={styles.Positioner} sideOffset={8}>
          <Popover.Popup
            class={styles.Popup}
            render={(props, state) => <AnimatedPopup {...props} open={state.open} />}
          >
            Popup
          </Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </Popover.Root>
  );
}

Specify keepMounted on the portal and animate based on state.open in the render callback.

Animating Select

import { createSignal, For } from 'solid-js';
import type { JSX } from '@solidjs/web';
import { Select } from 'base-ui-solid/select';
import { AnimatedPopup } from './animated-popup';
import styles from './index.module.css';

const fonts = [
  { label: 'Select font', value: null },
  { label: 'Sans-serif', value: 'sans' },
  { label: 'Serif', value: 'serif' },
  { label: 'Monospace', value: 'mono' },
  { label: 'Cursive', value: 'cursive' },
];

export default function AnimatedSelectMotionDemo() {
  const [open, setOpen] = createSignal(false);
  return (
    <Select.Root items={fonts} open={open()} onOpenChange={setOpen}>
      <Select.Trigger class={styles.Select}>
        <Select.Value class={styles.Value} />
        <Select.Icon>
          <CaretUpDownIcon />
        </Select.Icon>
      </Select.Trigger>
      <div style={{ display: 'contents' }}>
        {
          <Select.Portal>
            <Select.Positioner class={styles.Positioner} sideOffset={4}>
              <Select.Popup
                class={styles.Popup}
                render={(props, state) => <AnimatedPopup {...props} open={state.open} />}
              >
                <Select.ScrollUpArrow class={styles.ScrollArrow} />
                <Select.List class={styles.List}>
                  <For each={fonts}>
                    {({ label, value }) => (
                      <Select.Item value={value} class={styles.Item}>
                        <Select.ItemIndicator class={styles.ItemIndicator}>
                          <CheckIcon />
                        </Select.ItemIndicator>
                        <Select.ItemText class={styles.ItemText}>{label}</Select.ItemText>
                      </Select.Item>
                    )}
                  </For>
                </Select.List>
                <Select.ScrollDownArrow class={styles.ScrollArrow} />
              </Select.Popup>
            </Select.Positioner>
          </Select.Portal>
        }
      </div>
    </Select.Root>
  );
}

function CaretUpDownIcon(props: JSX.IntrinsicElements['svg']) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="currentColor"
      {...props}
      style={props.style}
    >
      <path d="M11 10H5l3 3.5zm0-4H5l3-3.5z" />
    </svg>
  );
}

function CheckIcon(props: JSX.IntrinsicElements['svg']) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      {...props}
      style={props.style}
    >
      <path d="m2.5 8.5 4 4 7-9" />
    </svg>
  );
}

Manual unmounting

Use this when Base UI cannot detect your closing animation, for example when it does not animate opacity, or when the popup should stay in its closing phase until something other than an animation completes.

Call eventDetails.preventUnmountOnClose() in onOpenChange when the component closes, then call unmount() on the actions passed to the <Root> once the animation finishes. This ends the closing phase, and onOpenChangeComplete(false) fires. Whether the popup leaves the DOM is still decided by keepMounted.

manual-unmount.tsx
import { createSignal } from 'solid-js';
import { Popover } from 'base-ui-solid/popover';

function App() {
  const [open, setOpen] = createSignal(false);
  let actions: Popover.Root.Actions | null = null;
  return (
    <Popover.Root
      open={open()}
      actionsRef={(next) => {
        actions = next;
      }}
      onOpenChange={(nextOpen, details) => {
        if (!nextOpen) details.preventUnmountOnClose();
        setOpen(nextOpen);
      }}
    >
      <Popover.Trigger>Trigger</Popover.Trigger>
      <Popover.Portal>
        <Popover.Positioner>
          <Popover.Popup
            render={(props, state) => (
              <div
                {...props}
                onAnimationEnd={() => {
                  if (!state.open) actions?.unmount();
                }}
              />
            )}
          >
            Popup
          </Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </Popover.Root>
  );
}