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.
.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.
@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.
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>
);
}