---
title: Animation
subtitle: A guide to animating Base UI components.
description: A guide to animating Base UI components.
---

> FineSoft Components documentation. Independent Stencil port; not the official Base UI website.
>
> React API text and `@base-ui/react` examples are upstream references. FineSoft Stencil examples use `finesoft-components`. Do not treat the two packages as interchangeable.

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

The runnable examples below compare the frozen React implementation with FineSoft Stencil. Each Stencil source declares native `<fs-*>` elements and uses Motion's framework-independent `animate()` API directly on the Popup. The public unmount API keeps the closing popup available until the animation finishes. The two Popover examples differ in whether the closed Portal stays mounted.

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

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

  /* @highlight-start */
  &[data-starting-style],
  &[data-ending-style] {
    opacity: 0;
    transform: scale(0.9);
  }
  /* @highlight-end */
}
```

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

```css title="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 such as [Motion](https://motion.dev) require control of the mounting and unmounting lifecycle of components in order for exit animations to play.

Base UI relies on [`element.getAnimations()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getAnimations) to detect if animations have finished on an element. When using Motion, `opacity` animations are reflected in `element.getAnimations()`, so Base UI automatically waits for the animation finish before unmounting the component. If `opacity` isn't part of your animation (such as in a translating drawer component), you should still animate it using a value close to `1` (such as `opacity: 0.9999`), so that Base UI can detect the animation.

### Animating components unmounted from DOM when closed with Motion

Most popup components like Popover, Dialog, Tooltip, and Menu are unmounted from the DOM when they are closed by default. To animate them with Motion:

- Make the component controlled with the `open` prop so `<AnimatePresence>` can see the state as a child
- Specify `keepMounted` on the `<Portal>` part
- Use the `render` prop to compose the `<Popup>` with `motion.div`

## Demo

### CSS Modules

This example shows how to implement the component using CSS Modules.

```css
/* index.module.css */
.Trigger {
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 2rem;
  padding: 0 0.75rem;
  margin: 0;
  outline: 0;
  border: 1px solid oklch(14.5% 0 0deg);
  background-color: white;
  font-family: inherit;
  font-size: 0.875rem;
  font-weight: 400;
  line-height: 1.25rem;
  color: oklch(14.5% 0 0deg);
  -webkit-user-select: none;
  user-select: none;

  @media (prefers-color-scheme: dark) {
    border: 1px solid white;
    background-color: oklch(14.5% 0 0deg);
    color: white;
  }

  @media (hover: hover) {
    &:hover {
      background-color: oklch(97% 0 0deg);

      @media (prefers-color-scheme: dark) {
        background-color: oklch(26.9% 0 0deg);
      }
    }
  }

  &:active {
    background-color: oklch(92.2% 0 0deg);

    @media (prefers-color-scheme: dark) {
      background-color: oklch(37.1% 0 0deg);
    }
  }

  &[data-pressed] {
    background-color: oklch(97% 0 0deg);

    @media (prefers-color-scheme: dark) {
      background-color: oklch(26.9% 0 0deg);
    }
  }

  &:focus-visible {
    outline: 2px solid oklch(14.5% 0 0deg);
    outline-offset: -1px;

    @media (prefers-color-scheme: dark) {
      outline-color: white;
    }
  }
}

.Positioner {
  width: var(--positioner-width);
  height: var(--positioner-height);
  max-width: var(--available-width);
}

.Popup {
  box-sizing: border-box;
  padding: 0.75rem 1rem;
  border: 1px solid oklch(14.5% 0 0deg);
  background-color: white;
  color: oklch(14.5% 0 0deg);
  font-size: 0.875rem;
  line-height: 1.25rem;
  transform-origin: var(--transform-origin);
  box-shadow: 0.25rem 0.25rem 0 rgb(0 0 0 / 12%);
  outline: none;

  width: var(--popup-width, auto);
  height: var(--popup-height, auto);
  max-width: 500px;

  @media (prefers-color-scheme: dark) {
    border: 1px solid white;
    background-color: oklch(14.5% 0 0deg);
    color: white;
    box-shadow: none;
  }
}
```

```tsx
/* docs-comparison.tsx */
import { StencilComparison } from 'docs/src/components/StencilComparison';
import ReactExample from './react-animated-popover-motion-keep-mounted-false';
import StencilExample from './docs-native-preview';
import { source } from './stencil-popover';
export default function Comparison() {
  return <StencilComparison component="handbook-popover-false" react={<ReactExample />} stencil={null} renderSource={() => source} clientStencil={<StencilExample />} />;
}
```

```tsx
/* react-animated-popover-motion-keep-mounted-false.tsx */
'use client';
import * as React from 'react';
import { Popover } from '@base-ui/react/popover';
import { AnimatePresence, motion } from 'motion/react';
import styles from './index.module.css';

export default function AnimatedPopoverMotionKeepMountedFalseDemo() {
  const [open, setOpen] = React.useState(false);

  return (
    <Popover.Root open={open} onOpenChange={setOpen}>
      <Popover.Trigger className={styles.Trigger}>Trigger</Popover.Trigger>
      <AnimatePresence>
        {open && (
          <Popover.Portal keepMounted>
            <Popover.Positioner className={styles.Positioner} sideOffset={8}>
              <Popover.Popup
                className={styles.Popup}
                render={<motion.div initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }} />}
              >
                Popup
              </Popover.Popup>
            </Popover.Positioner>
          </Popover.Portal>
        )}
      </AnimatePresence>
    </Popover.Root>
  );
}
```

```tsx
/* docs-native-preview.tsx */
'use client';
import { NativeStencilPreview } from 'docs/src/components/NativeStencilPreview';
import { mount } from './stencil-popover';
export default function Preview() {
  return <NativeStencilPreview mount={mount} />;
}
```

```ts
/* stencil-popover.ts */
import { animate, type AnimationPlaybackControlsWithThen } from 'motion';
import type { Components, PopoverOpenChangeDetail, PopoverRootActions } from 'finesoft-components';
import './stencil.css';

export const source = `
<fs-popover-root>
  <fs-popover-trigger class="demo-handbook-animation-animated-popover-motion-keep-mounted-false-trigger">Trigger</fs-popover-trigger>
  <fs-popover-portal>
    <fs-popover-positioner side-offset="8">
      <fs-popover-popup class="${'demo-handbook-animation-animated-popover-motion-keep-mounted-false-popup'}" style="opacity:0;transform:scale(0.8)">Popup</fs-popover-popup>
    </fs-popover-positioner>
  </fs-popover-portal>
</fs-popover-root>
`;

export function mount(container: HTMLElement) {
  const template = container.ownerDocument.createElement('template');
  template.innerHTML = source;
  const root = template.content.querySelector('fs-popover-root') as HTMLElement & Components.FsPopoverRoot;
  const popup = template.content.querySelector<HTMLElement>('fs-popover-popup')!;
  const actions: { current: PopoverRootActions | null } = { current: null };
  root.actionsRef = actions;
  let animation: AnimationPlaybackControlsWithThen | undefined;
  let version = 0;
  const change = (event: CustomEvent<PopoverOpenChangeDetail>) => {
    const { open } = event.detail;
    if (!open) event.detail.preventUnmountOnClose();
    const current = ++version;
    queueMicrotask(() => {
      if (event.detail.isCanceled || current !== version) return;
      animation?.stop();
      // Motion's DOM API uses the same opacity easing and scale spring as motion.div.
      animation = animate(popup, { opacity: open ? 1 : 0, scale: open ? 1 : 0.8 });
      void animation.then(() => {
        if (!open && current === version) actions.current?.unmount();
      });
    });
  };
  root.addEventListener('openChange', change as EventListener);
  container.append(template.content);
  return () => {
    version++;
    animation?.stop();
    root.removeEventListener('openChange', change as EventListener);
    root.remove();
  };
}
```

```css
/* stencil.css */
/* Example layout and visual exceptions. Default component styles come from the theme. */
@layer fs-theme {
  .demo-handbook-animation-animated-popover-motion-keep-mounted-false-trigger::part(trigger) {
    line-height: var(--fs-line-height-sm);
  }

  .demo-handbook-animation-animated-popover-motion-keep-mounted-false-popup {
    --fs-popover-popup-padding: var(--fs-space-3) var(--fs-space-4);
    --fs-popover-popup-transition: none;
    font-size: var(--fs-font-size-sm);
    line-height: var(--fs-line-height-sm);
  }
}
```

```jsx title="animated-popover.tsx"
function App() {
  const [open, setOpen] = React.useState(false);

  return (
    <Popover.Root open={open} onOpenChange={setOpen}>
      <Popover.Trigger>Trigger</Popover.Trigger>
      <AnimatePresence>
        {open && (
          <Popover.Portal keepMounted> {/* @highlight-text "keepMounted" */}
            <Popover.Positioner>
              <Popover.Popup
                {/* @highlight-start */}
                render={
                  <motion.div
                    initial={{ opacity: 0, scale: 0.8 }}
                    animate={{ opacity: 1, scale: 1 }}
                    exit={{ opacity: 0, scale: 0.8 }}
                  />
                }
                {/* @highlight-end */}
              >
                Popup
              </Popover.Popup>
            </Popover.Positioner>
          </Popover.Portal>
        )}
      </AnimatePresence>
    </Popover.Root>
  );
}
```

### Animating components kept in DOM when closed with Motion

Components that specify `keepMounted` remain rendered in the DOM when they are closed. These elements need a different approach to be animated with Motion:

- Use the `render` prop to compose the `<Popup>` with `motion.div`
- Animate the properties based on the `open` state, avoiding `<AnimatePresence>`

## Demo

### CSS Modules

This example shows how to implement the component using CSS Modules.

```css
/* index.module.css */
.Trigger {
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 2rem;
  padding: 0 0.75rem;
  margin: 0;
  outline: 0;
  border: 1px solid oklch(14.5% 0 0deg);
  background-color: white;
  font-family: inherit;
  font-size: 0.875rem;
  font-weight: 400;
  line-height: 1.25rem;
  color: oklch(14.5% 0 0deg);
  -webkit-user-select: none;
  user-select: none;

  @media (prefers-color-scheme: dark) {
    border: 1px solid white;
    background-color: oklch(14.5% 0 0deg);
    color: white;
  }

  @media (hover: hover) {
    &:hover {
      background-color: oklch(97% 0 0deg);

      @media (prefers-color-scheme: dark) {
        background-color: oklch(26.9% 0 0deg);
      }
    }
  }

  &:active {
    background-color: oklch(92.2% 0 0deg);

    @media (prefers-color-scheme: dark) {
      background-color: oklch(37.1% 0 0deg);
    }
  }

  &[data-pressed] {
    background-color: oklch(97% 0 0deg);

    @media (prefers-color-scheme: dark) {
      background-color: oklch(26.9% 0 0deg);
    }
  }

  &:focus-visible {
    outline: 2px solid oklch(14.5% 0 0deg);
    outline-offset: -1px;

    @media (prefers-color-scheme: dark) {
      outline-color: white;
    }
  }
}

.Positioner {
  width: var(--positioner-width);
  height: var(--positioner-height);
  max-width: var(--available-width);
}

.Popup {
  box-sizing: border-box;
  padding: 0.75rem 1rem;
  border: 1px solid oklch(14.5% 0 0deg);
  background-color: white;
  color: oklch(14.5% 0 0deg);
  font-size: 0.875rem;
  line-height: 1.25rem;
  transform-origin: var(--transform-origin);
  box-shadow: 0.25rem 0.25rem 0 rgb(0 0 0 / 12%);
  width: var(--popup-width, auto);
  height: var(--popup-height, auto);
  max-width: 500px;
  outline: none;

  @media (prefers-color-scheme: dark) {
    border: 1px solid white;
    background-color: oklch(14.5% 0 0deg);
    color: white;
    box-shadow: none;
  }
}
```

```tsx
/* docs-comparison.tsx */
import { StencilComparison } from 'docs/src/components/StencilComparison';
import ReactExample from './react-animated-popover-motion-keep-mounted-true';
import StencilExample from './docs-native-preview';
import { source } from './stencil-popover';
export default function Comparison() {
  return <StencilComparison component="handbook-popover-true" react={<ReactExample />} stencil={null} renderSource={() => source} clientStencil={<StencilExample />} />;
}
```

```tsx
/* react-animated-popover-motion-keep-mounted-true.tsx */
'use client';
import * as React from 'react';
import { Popover } from '@base-ui/react/popover';
import { motion, type HTMLMotionProps } from 'motion/react';
import styles from './index.module.css';

export default function AnimatedPopoverMotionKeepMountedTrueDemo() {
  return (
    <Popover.Root>
      <Popover.Trigger className={styles.Trigger}>Trigger</Popover.Trigger>
      <Popover.Portal keepMounted>
        <Popover.Positioner className={styles.Positioner} sideOffset={8}>
          <Popover.Popup
            className={styles.Popup}
            render={(props, state) => (
              <motion.div
                {...(props as HTMLMotionProps<'div'>)}
                initial={false}
                animate={{
                  opacity: state.open ? 1 : 0,
                  scale: state.open ? 1 : 0.8,
                }}
              />
            )}
          >
            Popup
          </Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </Popover.Root>
  );
}
```

```tsx
/* docs-native-preview.tsx */
'use client';
import { NativeStencilPreview } from 'docs/src/components/NativeStencilPreview';
import { mount } from './stencil-popover';
export default function Preview() {
  return <NativeStencilPreview mount={mount} />;
}
```

```ts
/* stencil-popover.ts */
import { animate, type AnimationPlaybackControlsWithThen } from 'motion';
import type { Components, PopoverOpenChangeDetail, PopoverRootActions } from 'finesoft-components';
import './stencil.css';

export const source = `
<fs-popover-root>
  <fs-popover-trigger class="demo-handbook-animation-animated-popover-motion-keep-mounted-true-trigger">Trigger</fs-popover-trigger>
  <fs-popover-portal keep-mounted>
    <fs-popover-positioner side-offset="8">
      <fs-popover-popup class="${'demo-handbook-animation-animated-popover-motion-keep-mounted-true-popup'}" style="opacity:0;transform:scale(0.8)">Popup</fs-popover-popup>
    </fs-popover-positioner>
  </fs-popover-portal>
</fs-popover-root>
`;

export function mount(container: HTMLElement) {
  const template = container.ownerDocument.createElement('template');
  template.innerHTML = source;
  const root = template.content.querySelector('fs-popover-root') as HTMLElement & Components.FsPopoverRoot;
  const popup = template.content.querySelector<HTMLElement>('fs-popover-popup')!;
  const actions: { current: PopoverRootActions | null } = { current: null };
  root.actionsRef = actions;
  let animation: AnimationPlaybackControlsWithThen | undefined;
  let version = 0;
  const change = (event: CustomEvent<PopoverOpenChangeDetail>) => {
    const { open } = event.detail;
    if (!open) event.detail.preventUnmountOnClose();
    const current = ++version;
    queueMicrotask(() => {
      if (event.detail.isCanceled || current !== version) return;
      animation?.stop();
      // Motion's DOM API uses the same opacity easing and scale spring as motion.div.
      animation = animate(popup, { opacity: open ? 1 : 0, scale: open ? 1 : 0.8 });
      void animation.then(() => {
        if (!open && current === version) actions.current?.unmount();
      });
    });
  };
  root.addEventListener('openChange', change as EventListener);
  container.append(template.content);
  return () => {
    version++;
    animation?.stop();
    root.removeEventListener('openChange', change as EventListener);
    root.remove();
  };
}
```

```css
/* stencil.css */
/* Example layout and visual exceptions. Default component styles come from the theme. */
@layer fs-theme {
  .demo-handbook-animation-animated-popover-motion-keep-mounted-true-trigger::part(trigger) {
    line-height: var(--fs-line-height-sm);
  }

  .demo-handbook-animation-animated-popover-motion-keep-mounted-true-popup {
    --fs-popover-popup-padding: var(--fs-space-3) var(--fs-space-4);
    --fs-popover-popup-transition: none;
    font-size: var(--fs-font-size-sm);
    line-height: var(--fs-line-height-sm);
  }
}
```

```jsx title="animated-popover.tsx"
function App() {
  return (
    <Popover.Root>
      <Popover.Trigger>Trigger</Popover.Trigger>
      <Popover.Portal keepMounted> {/* @highlight-text "keepMounted" */}
        <Popover.Positioner>
          <Popover.Popup
            // @highlight-start
            render={(props, state) => (
              <motion.div
                {...(props as HTMLMotionProps<'div'>)}
                initial={false}
                animate={{
                  opacity: state.open ? 1 : 0,
                  scale: state.open ? 1 : 0.8,
                }}
              />
            )}
            {/* @highlight-end */}
          >
            Popup
          </Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </Popover.Root>
  );
}
```

### Animating Select component with Motion

The Select component is initially unmounted but remains mounted after interaction. To animate it with Motion, a mix of the two previous approaches is needed.

## Demo

### CSS Modules

This example shows how to implement the component using CSS Modules.

```css
/* index.module.css */
.Select {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  height: 2rem;
  padding-left: 0.5rem;
  padding-right: 0.25rem;
  margin: 0;
  outline: 0;
  border: 1px solid oklch(14.5% 0 0deg);
  background-color: white;
  font-family: inherit;
  font-size: 0.875rem;
  line-height: 1.25rem;
  font-weight: 400;
  color: oklch(14.5% 0 0deg);
  -webkit-user-select: none;
  user-select: none;
  min-width: 9rem;

  @media (prefers-color-scheme: dark) {
    border: 1px solid white;
    background-color: oklch(14.5% 0 0deg);
    color: white;
  }

  @media (hover: hover) {
    &:hover {
      background-color: oklch(97% 0 0deg);

      @media (prefers-color-scheme: dark) {
        background-color: oklch(26.9% 0 0deg);
      }
    }
  }

  &[data-pressed] {
    background-color: oklch(97% 0 0deg);

    @media (prefers-color-scheme: dark) {
      background-color: oklch(26.9% 0 0deg);
    }
  }

  &:active {
    background-color: oklch(92.2% 0 0deg);

    @media (prefers-color-scheme: dark) {
      background-color: oklch(37.1% 0 0deg);
    }
  }

  &:focus-visible {
    outline: 2px solid oklch(14.5% 0 0deg);
    outline-offset: -1px;

    @media (prefers-color-scheme: dark) {
      outline-color: white;
    }
  }
}

.Value[data-placeholder] {
  color: oklch(55.6% 0 0deg);

  @media (prefers-color-scheme: dark) {
    color: oklch(70.8% 0 0deg);
  }
}

.Positioner {
  outline: none;
  z-index: 10;
  -webkit-user-select: none;
  user-select: none;
}

.Popup {
  box-sizing: border-box;
  outline: 0;
  border: 1px solid oklch(14.5% 0 0deg);
  background-color: white;
  background-clip: padding-box;
  color: oklch(14.5% 0 0deg);
  min-width: var(--anchor-width);
  transform-origin: var(--transform-origin);
  box-shadow: 0.25rem 0.25rem 0 rgb(0 0 0 / 12%);

  @media (prefers-color-scheme: dark) {
    border: 1px solid white;
    background-color: oklch(14.5% 0 0deg);
    color: white;
    box-shadow: none;
  }

  &[data-side='none'] {
    min-width: calc(var(--anchor-width) + 1.75rem);
  }
}

.List {
  box-sizing: border-box;
  position: relative;
  padding-block: 0.25rem;
  overflow-y: auto;
  max-height: var(--available-height);
  scroll-padding-block: 1.5rem;
}

.Arrow {
  display: flex;

  &[data-side='top'] {
    bottom: -8px;
    rotate: 180deg;
  }

  &[data-side='bottom'] {
    top: -8px;
    rotate: 0deg;
  }

  &[data-side='left'] {
    right: -13px;
    rotate: 90deg;
  }

  &[data-side='right'] {
    left: -13px;
    rotate: -90deg;
  }
}

.ArrowFill {
  fill: white;

  @media (prefers-color-scheme: dark) {
    fill: oklch(14.5% 0 0deg);
  }
}

.ArrowOuterStroke {
  fill: oklch(14.5% 0 0deg);

  @media (prefers-color-scheme: dark) {
    fill: white;
  }
}

.ArrowInnerStroke {
  fill: white;

  @media (prefers-color-scheme: dark) {
    fill: oklch(14.5% 0 0deg);
  }
}

.Item {
  box-sizing: border-box;
  outline: 0;
  font-size: 0.875rem;
  line-height: 1.25rem;
  padding-block: 0.375rem;
  padding-left: 0.625rem;
  padding-right: 1rem;
  display: grid;
  gap: 0.5rem;
  align-items: center;
  grid-template-columns: 1rem 1fr;
  cursor: default;
  -webkit-user-select: none;
  user-select: none;

  &[data-highlighted] {
    background-color: oklch(14.5% 0 0deg);
    color: white;

    @media (prefers-color-scheme: dark) {
      background-color: white;
      color: oklch(14.5% 0 0deg);
    }
  }
}

.ItemIndicator {
  grid-column-start: 1;
}

.ItemText {
  grid-column-start: 2;
}

.ScrollArrow {
  width: 100%;
  background-color: white;
  z-index: 1;
  text-align: center;
  cursor: default;
  height: 1rem;
  font-size: 0.75rem;
  display: flex;
  align-items: center;
  justify-content: center;

  @media (prefers-color-scheme: dark) {
    background-color: oklch(14.5% 0 0deg);
  }

  &::before {
    content: '';
    position: absolute;
    width: 100%;
    height: 100%;
    left: 0;
  }

  &[data-direction='up'] {
    top: 0;

    &[data-side='none'] {
      &::before {
        top: -100%;
      }
    }
  }

  &[data-direction='down'] {
    bottom: 0;

    &[data-side='none'] {
      &::before {
        bottom: -100%;
      }
    }
  }
}
```

```tsx
/* docs-comparison.tsx */
import { StencilComparison } from 'docs/src/components/StencilComparison';
import ReactExample from './react-animated-select-motion';
import StencilExample from './docs-native-preview';
import { source, configure } from './stencil-select';
export default function Comparison() {
  return (
    <StencilComparison
      component="handbook-select-motion"
      react={<ReactExample />}
      stencil={null}
      renderSource={() => source}
      beforeHydrate={configure}
      clientStencil={<StencilExample />}
    />
  );
}
```

```tsx
/* react-animated-select-motion.tsx */
'use client';
import * as React from 'react';
import { Select } from '@base-ui/react/select';
import { AnimatePresence, motion } from 'motion/react';
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] = React.useState(false);
  const [mounted, setMounted] = React.useState(false);

  const positionerRef = React.useCallback(() => {
    setMounted(true);
  }, []);

  const portalMounted = open || mounted;

  // Once the trigger has been interacted with, the popup will always be
  // mounted in the DOM. We can use this to determine which animation variant
  // to use: if it's already mounted, we switch to use "keepMounted" animations.
  const motionElement = mounted ? (
    <motion.div
      initial={false}
      animate={{
        opacity: open ? 1 : 0,
        scale: open ? 1 : 0.8,
      }}
    />
  ) : (
    <motion.div initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }} />
  );

  return (
    <Select.Root items={fonts} open={open} onOpenChange={setOpen}>
      <Select.Trigger className={styles.Select}>
        <Select.Value className={styles.Value} />
        <Select.Icon>
          <CaretUpDownIcon />
        </Select.Icon>
      </Select.Trigger>
      <AnimatePresence>
        {portalMounted && (
          <Select.Portal>
            <Select.Positioner className={styles.Positioner} sideOffset={4} ref={positionerRef}>
              <Select.Popup className={styles.Popup} render={motionElement}>
                <Select.ScrollUpArrow className={styles.ScrollArrow} />
                <Select.List className={styles.List}>
                  {fonts.map(({ label, value }) => (
                    <Select.Item key={label} value={value} className={styles.Item}>
                      <Select.ItemIndicator className={styles.ItemIndicator}>
                        <CheckIcon />
                      </Select.ItemIndicator>
                      <Select.ItemText className={styles.ItemText}>{label}</Select.ItemText>
                    </Select.Item>
                  ))}
                </Select.List>
                <Select.ScrollDownArrow className={styles.ScrollArrow} />
              </Select.Popup>
            </Select.Positioner>
          </Select.Portal>
        )}
      </AnimatePresence>
    </Select.Root>
  );
}

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

function CheckIcon(props: React.ComponentProps<'svg'>) {
  return (
    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" {...props} style={{ display: 'block', ...props.style }}>
      <path d="m2.5 8.5 4 4 7-9" />
    </svg>
  );
}
```

```tsx
/* docs-native-preview.tsx */
'use client';
import { NativeStencilPreview } from 'docs/src/components/NativeStencilPreview';
import { mount } from './stencil-select';
export default function Preview() {
  return <NativeStencilPreview mount={mount} />;
}
```

```ts
/* stencil-select.ts */
import { animate, type AnimationPlaybackControlsWithThen } from 'motion';
import type { Components, SelectOpenChangeDetail, SelectRootActions } from 'finesoft-components';
import './stencil.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 const source = `
<fs-select-root>
  <fs-select-trigger class="${'demo-handbook-animation-animated-select-motion-select'}">
    <fs-select-value></fs-select-value>
    <fs-select-icon>
      <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style="display:block"><path d="M11 10H5l3 3.5zm0-4H5l3-3.5z"></path></svg>
    </fs-select-icon>
  </fs-select-trigger>
  <fs-select-portal>
    <fs-select-positioner side-offset="4">
      <fs-select-popup class="${'demo-handbook-animation-animated-select-motion-popup'}" style="opacity:0;transform:scale(0.8)">
        <fs-select-scroll-up-arrow></fs-select-scroll-up-arrow>
        <fs-select-list>
          ${fonts
            .map(
              font => `
          <fs-select-item>
            <fs-select-item-indicator>
              <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" style="display:block"><path d="m2.5 8.5 4 4 7-9"></path></svg>
            </fs-select-item-indicator>
            <fs-select-item-text>${font.label}</fs-select-item-text>
          </fs-select-item>`,
            )
            .join('')}
        </fs-select-list>
        <fs-select-scroll-down-arrow></fs-select-scroll-down-arrow>
      </fs-select-popup>
    </fs-select-positioner>
  </fs-select-portal>
</fs-select-root>
`;

export function configure(scope: ParentNode) {
  (scope.querySelector('fs-select-root') as HTMLElement & Components.FsSelectRoot).items = fonts;
  scope.querySelectorAll('fs-select-item').forEach((element, index) => {
    (element as HTMLElement & Components.FsSelectItem).value = fonts[index].value;
  });
}

export function mount(container: HTMLElement) {
  const template = container.ownerDocument.createElement('template');
  template.innerHTML = source;
  configure(template.content);
  const root = template.content.querySelector('fs-select-root') as HTMLElement & Components.FsSelectRoot;
  const popup = template.content.querySelector<HTMLElement>('fs-select-popup')!;
  const actions: { current: SelectRootActions | null } = { current: null };
  // Select retains its parts after the first interaction; actionsRef lets Motion
  // finish the closing animation before the component completes that lifecycle.
  root.actionsRef = actions;
  let animation: AnimationPlaybackControlsWithThen | undefined;
  let version = 0;
  const change = (event: CustomEvent<SelectOpenChangeDetail>) => {
    const { open } = event.detail;
    const current = ++version;
    queueMicrotask(() => {
      if (event.detail.isCanceled || current !== version) return;
      animation?.stop();
      animation = animate(popup, { opacity: open ? 1 : 0, scale: open ? 1 : 0.8 });
      void animation.then(() => {
        if (!open && current === version) actions.current?.unmount();
      });
    });
  };
  root.addEventListener('openChange', change as EventListener);
  container.append(template.content);
  return () => {
    version++;
    animation?.stop();
    root.removeEventListener('openChange', change as EventListener);
    root.remove();
  };
}
```

```css
/* stencil.css */
/* Example layout and visual exceptions. Default component styles come from the theme. */
@layer fs-theme {
  .demo-handbook-animation-animated-select-motion-select {
    --fs-select-min-width: 9rem;

    &::part(trigger) {
      line-height: var(--fs-line-height-sm);
    }
  }

  .demo-handbook-animation-animated-select-motion-popup {
    --fs-select-popup-transition: none;
  }
}
```

### Manual unmounting

For full control, you can manually unmount the component when it's closed once animations have finished using an `actionsRef` passed to the `<Root>`:

```jsx title="manual-unmount.tsx"
function App() {
  const [open, setOpen] = React.useState(false);
  const actionsRef = React.useRef(null); // @highlight-text "actionsRef"

  return (
    {/* @highlight-text "actionsRef" */}
    <Popover.Root open={open} onOpenChange={setOpen} actionsRef={actionsRef}>
      <Popover.Trigger>Trigger</Popover.Trigger>
      <AnimatePresence>
        {open && (
          <Popover.Portal keepMounted>
            <Popover.Positioner>
              <Popover.Popup
                render={
                  <motion.div
                    initial={{ scale: 0 }}
                    animate={{ scale: 1 }}
                    exit={{ scale: 0 }}
                    onAnimationComplete={() => {
                      if (!open) {
                        actionsRef.current.unmount(); // @highlight-text "actionsRef"
                      }
                    }}
                  />
                }
              >
                Popup
              </Popover.Popup>
            </Popover.Positioner>
          </Popover.Portal>
        )}
      </AnimatePresence>
    </Popover.Root>
  );
}
```
