# Animation

Every part of a component exposes its state as a data attribute, and parts are plain DOM elements. That leaves the animation up to you: a CSS transition, a keyframe animation or a JavaScript library all work, and the patterns below show how a few common cases fit together.

## CSS transitions

A part that opens carries `data-state="open"` or `data-state="closed"`. Transition between the two and most enter and exit animations need no JavaScript:

```css
[data-popover-content] {
    transform-origin: var(--transform-origin);
    transition:
        opacity 250ms,
        scale 250ms,
        display 250ms allow-discrete;

    &[data-state='closed'] {
        opacity: 0;
    }

    @starting-style {
        &[data-state='open'] {
            opacity: 0;
        }
    }

    @media (prefers-reduced-motion: no-preference) {
        &[data-state='closed'] {
            scale: 0.95;
        }

        @starting-style {
            &[data-state='open'] {
                scale: 0.95;
            }
        }
    }
}
```

Two CSS features make this work for parts that are hidden while closed:

- [`@starting-style`](https://developer.mozilla.org/en-US/docs/Web/CSS/@starting-style) gives a part the style to transition from on the frame it appears.
- [`transition-behavior: allow-discrete`](https://developer.mozilla.org/en-US/docs/Web/CSS/transition-behavior) lets `display` take part in the transition. Fluid Primitives sets the `hidden` attribute as soon as a part closes, and `allow-discrete` postpones it until the exit has played.

> **Quick Tip** - Use transitions over animation keyframes because they are interruptible.

### Scale from the trigger

Popover, Menu, Select, Combobox, Tooltip and DatePicker set `--transform-origin` on their positioner to the point closest to the trigger. Pick a side and open the popover.

**AnimationExamples.html**

```html
<div class="grid justify-items-center gap-28">
    <ui:toggleGroup.root deselectable="{false}" defaultValue="{0: 'bottom'}" aria-label="Popover placement" autoMount="{false}" rootId="placement-group">
        <ui:toggleGroup.item value="top">Top</ui:toggleGroup.item>
        <ui:toggleGroup.item value="right">Right</ui:toggleGroup.item>
        <ui:toggleGroup.item value="bottom">Bottom</ui:toggleGroup.item>
        <ui:toggleGroup.item value="left">Left</ui:toggleGroup.item>
    </ui:toggleGroup.root>

    <primitives:popover.root positioning="{placement: 'bottom', gutter: 8}" autoMount="{false}" rootId="placement-popover">
        <primitives:popover.trigger class="inline-flex h-9 cursor-pointer items-center rounded-md border bg-background px-4 text-sm font-medium shadow-xs outline-none hover:bg-accent focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50">
            Delivery options
        </primitives:popover.trigger>
        <ui:portal>
            <primitives:popover.positioner>
                <primitives:popover.content class="
                    w-64 origin-(--transform-origin) rounded-lg border bg-popover p-3 text-sm text-popover-foreground shadow-md outline-none
                    transition-[opacity,scale,display] duration-250 ease-[cubic-bezier(0.23,1,0.32,1)] transition-discrete
                    starting:data-[state=open]:opacity-0 data-[state=closed]:opacity-0
                    motion-safe:starting:data-[state=open]:scale-95 motion-safe:data-[state=closed]:scale-95
                ">
                    <primitives:popover.title class="font-medium">Delivery options</primitives:popover.title>
                    <primitives:popover.description class="mt-1 text-muted-foreground">Choose standard or express shipping at checkout.</primitives:popover.description>
                </primitives:popover.content>
            </primitives:popover.positioner>
        </ui:portal>
    </primitives:popover.root>
</div>

<vite:asset entry="EXT:docs/Resources/Private/Components/ui/AnimationExamples/PopoverPlacement.entry.ts" />

```

**PopoverPlacement.entry.ts**

```ts
import { mount } from 'fluid-primitives';
import { Popover } from 'fluid-primitives/popover';
import { ToggleGroup } from 'fluid-primitives/toggle-group';

const popover = mount('primitives:popover', 'placement-popover', ({ props }) =>
    new Popover(props).init()
);

mount('ui:toggleGroup', 'placement-group', ({ props }) =>
    new ToggleGroup({
        ...props,
        onValueChange: ({ value }) =>
            popover?.updateProps({
                positioning: {
                    gutter: 8,
                    placement: value[0] as 'top' | 'right' | 'bottom' | 'left',
                },
            }),
    }).init()
);

```

### Swap an indicator

A pressed Toggle carries `data-pressed`. Two icons in one grid cell and a `group-data-[pressed]:` variant crossfade the icons, here with a short blur and scale.

**AnimationExamples.html**

```html
<primitives:toggle.root
    aria-label="Dark mode"
    class="group inline-flex size-9 cursor-pointer items-center justify-center rounded-md border bg-background shadow-xs outline-none hover:bg-accent focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50">
    <span class="grid *:col-start-1 *:row-start-1 *:size-4">
        <svg
            class="transition-[opacity,scale,filter] duration-150 ease-out group-data-[pressed]:opacity-0 motion-safe:group-data-[pressed]:scale-50 motion-safe:group-data-[pressed]:blur-[4px]"
            xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <circle cx="12" cy="12" r="4" />
            <path d="M12 2v2" />
            <path d="M12 20v2" />
            <path d="m4.93 4.93 1.41 1.41" />
            <path d="m17.66 17.66 1.41 1.41" />
            <path d="M2 12h2" />
            <path d="M20 12h2" />
            <path d="m6.34 17.66-1.41 1.41" />
            <path d="m19.07 4.93-1.41 1.41" />
        </svg>
        <svg
            class="opacity-0 transition-[opacity,scale,filter] duration-150 ease-out group-data-[pressed]:opacity-100 motion-safe:scale-50 motion-safe:blur-[4px] motion-safe:group-data-[pressed]:scale-100 motion-safe:group-data-[pressed]:blur-none"
            xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
        </svg>
    </span>
</primitives:toggle.root>

<vite:asset entry="EXT:docs/Resources/Private/Components/ui/AnimationExamples/ToggleIcon.entry.ts" />

```

**ToggleIcon.entry.ts**

```ts
import { mountAll } from 'fluid-primitives';
import { Toggle } from 'fluid-primitives/toggle';

mountAll('primitives:toggle', ({ props }) => new Toggle(props).init());

```

Collapsible and Button indicators switch with the `hidden` attribute, so they use the same two features as a part that opens: `starting:` styles the indicator that appears and a `[&[hidden]]:` rule styles the one that leaves.

**AnimationExamples.html**

```html
<f:variable name="swap" value="
    transition-[opacity,scale,filter,display] duration-150 ease-out transition-discrete
    starting:opacity-0 motion-safe:starting:scale-50 motion-safe:starting:blur-[4px]
    [&[hidden]]:opacity-0 motion-safe:[&[hidden]]:scale-50 motion-safe:[&[hidden]]:blur-[4px]
" />

<ui:collapsible.root class="w-72 text-sm">
    <ui:collapsible.trigger class="flex w-full cursor-pointer items-center justify-between rounded-md bg-accent px-3 py-2 font-semibold">
        Shipping details
        <span class="grid *:col-start-1 *:row-start-1 *:size-4">
            <ui:collapsible.indicator state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\CollapsibleIndicatorState::Closed')}" class="{swap}">
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
                    <path d="M5 12h14" />
                    <path d="M12 5v14" />
                </svg>
            </ui:collapsible.indicator>
            <ui:collapsible.indicator state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\CollapsibleIndicatorState::Open')}" class="{swap}">
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
                    <path d="M5 12h14" />
                </svg>
            </ui:collapsible.indicator>
        </span>
    </ui:collapsible.trigger>
    <ui:collapsible.content>
        <p class="px-3 pt-2 text-muted-foreground">Orders ship within 2 working days. Delivery takes 2 to 3 days inside Germany.</p>
    </ui:collapsible.content>
</ui:collapsible.root>

```

## JavaScript

CSS cannot animate everything. A list that changes its columns, an element that moves to another parent or a button that changes its width have no CSS transition to reach for. For these, hand the work to an animation library. The examples below use [Motion](https://motion.dev), and the same approach works with any library that animates DOM elements: the callbacks of a component (`onOpenChange`, `onValueChange`, ...) tell you when to start.

```bash
npm install motion
```

### Animate a layout change

Motion's `animateLayout` measures elements before and after a DOM change and animates the difference. Mark the elements with `data-layout` and make the change inside the callback:

```ts
import { animateLayout } from 'motion';

animateLayout(list, () => (list.dataset.view = 'list'), {
    type: 'spring',
    duration: 0.4,
    bounce: 0,
});
```

**AnimationExamples.html**

```html
<f:variable name="products" value="{0: {name: 'Trail runner', price: '120 €'}, 1: {name: 'Canvas sneaker', price: '75 €'}, 2: {name: 'Leather boot', price: '189 €'}, 3: {name: 'Rain jacket', price: '139 €'}, 4: {name: 'Wool beanie', price: '29 €'}, 5: {name: 'Day pack', price: '95 €'}}" />

<div class="grid w-full max-w-lg gap-4">
    <ui:toggleGroup.root deselectable="{false}" defaultValue="{0: 'grid'}" aria-label="Product view" autoMount="{false}" rootId="{rootId}-view">
        <ui:toggleGroup.item value="grid">Grid</ui:toggleGroup.item>
        <ui:toggleGroup.item value="list">List</ui:toggleGroup.item>
    </ui:toggleGroup.root>

    <ul {ui:ref(name: 'products')} data-view="grid" class="group/products grid grid-cols-3 gap-4 data-[view=list]:grid-cols-1 data-[view=list]:gap-3">
        <f:for each="{products}" as="product">
            <li data-layout class="flex flex-col gap-2 group-data-[view=list]/products:flex-row group-data-[view=list]/products:items-center group-data-[view=list]/products:gap-3">
                <div data-layout class="aspect-square w-full rounded-lg bg-muted group-data-[view=list]/products:size-12 group-data-[view=list]/products:w-12"></div>
                <div data-layout="position" class="text-sm">
                    <p class="font-medium">{product.name}</p>
                    <p class="text-muted-foreground">{product.price}</p>
                </div>
            </li>
        </f:for>
    </ul>
</div>

<vite:asset entry="EXT:docs/Resources/Private/Components/ui/AnimationExamples/LayoutView.entry.ts" />

```

**LayoutView.entry.ts**

```ts
import { mount, mountAll } from 'fluid-primitives';
import { ToggleGroup } from 'fluid-primitives/toggle-group';
import { animateLayout } from 'motion';

const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

mountAll('ui:animationExamples.layoutView', ({ props, createHydrator }) => {
    const products = createHydrator().query('products');
    if (!products) return;

    mount('ui:toggleGroup', `${props.id}-view`, ({ props }) =>
        new ToggleGroup({
            ...props,
            onValueChange: ({ value }) => {
                animateLayout(
                    products,
                    () => (products.dataset.view = value[0]),
                    reduceMotion.matches
                        ? { duration: 0 }
                        : { type: 'spring', duration: 0.4, bounce: 0 }
                );
            },
        }).init()
    );
});

```

- `data-layout` on a child keeps its proportions while the parent resizes. `data-layout="position"` moves a child without resizing it, which suits text.
- The scale of a box stretches its border and corners while it animates. The product images are square in both views and the rows have no surface, so nothing looks stretched.

> **Transitions on transform** - Motion writes `transform` on every frame. A CSS transition that includes it, like Tailwind's `transition-all`, works against it, so list the properties you transition.

### Move an element into a dialog

An element that moves to another parent animates from its old box to the new one. Here the whole card moves into the dialog and grows, while the image, title and price settle into their new places and the description fades in. The dialog panel stays still and the card brings its own background, so Motion measures the card where it lands.

On its way back the card stays in the layer of the dialog and flies to a fixed position over its button, so it never passes below the backdrop. Once it has landed, it moves back into the list.

**AnimationExamples.html**

```html
<f:variable name="products" value="{0: {id: 'trail-runner', name: 'Trail runner', price: '120 €', description: 'Light and grippy, made for wet forest paths. Comes with a spare pair of laces.'}, 1: {id: 'canvas-sneaker', name: 'Canvas sneaker', price: '75 €', description: 'A classic low-top in organic cotton canvas with a recycled rubber sole.'}, 2: {id: 'leather-boot', name: 'Leather boot', price: '189 €', description: 'Full-grain leather, resoleable and waterproof. Breaks in after two weeks.'}}" />

<ui:dialog.root autoMount="{false}" rootId="{rootId}-dialog">
    <ul class="grid w-full max-w-lg grid-cols-3 gap-4">
        <f:for each="{products}" as="product">
            <li>
                <ui:dialog.trigger value="{product.id}" asChild="{true}">
                    <button
                        type="button"
                        data-description="{product.description}"
                        class="block w-full cursor-pointer rounded-xl text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
                        <span data-card data-layout class="
                            flex flex-col gap-2 rounded-xl bg-muted p-2 text-sm
                            transition-[background-color] duration-(--duration) ease-out
                            in-data-dialog-content:bg-popover in-data-dialog-content:starting:bg-muted
                            [[data-dialog-positioner]>&]:starting:bg-popover
                            group/card
                        ">
                            <span data-layout class="block aspect-square rounded-lg bg-foreground/10 group-data-expanded/card:aspect-video"></span>
                            <span class="flex flex-col group-data-expanded/card:flex-row group-data-expanded/card:items-baseline group-data-expanded/card:justify-between">
                                <span data-title data-layout="position" class="block font-medium">{product.name}</span>
                                <span data-layout="position" class="block text-muted-foreground">{product.price}</span>
                            </span>
                        </span>
                    </button>
                </ui:dialog.trigger>
            </li>
        </f:for>
    </ul>

    <ui:portal>
        <primitives:dialog.backdrop class="
            fixed inset-0 z-(--layer-index,0) bg-black/50
            transition-[opacity,display] duration-300 ease-out transition-discrete
            starting:data-[state=open]:opacity-0
            data-[state=closed]:opacity-0
        " />
        <primitives:dialog.positioner class="fixed inset-y-0 left-0 z-(--layer-index,0) grid w-screen place-items-center p-6">
            <primitives:dialog.content class="group/dialog w-full max-w-xs outline-none transition-[display] duration-300 transition-discrete">
                <div {ui:ref(name: 'slot' )}></div>
                <div {ui:ref(name: 'extras' )} class="
                    grid gap-4 pt-2
                    transition-opacity delay-100 duration-300 ease-out starting:opacity-0 starting:blur-xs
                    group-data-[state=closed]/dialog:opacity-0 group-data-[state=closed]/dialog:delay-0 group-data-[state=closed]/dialog:duration-100 group-data-[state=closed]/dialog:blur-xs
                ">
                    <ui:dialog.description asChild="{true}">
                        <p {ui:ref(name: 'description' )} class="text-sm text-muted-foreground"></p>
                    </ui:dialog.description>
                    <ui:dialog.close asChild="{true}">
                        <ui:button>Close</ui:button>
                    </ui:dialog.close>
                </div>
            </primitives:dialog.content>
        </primitives:dialog.positioner>
    </ui:portal>
</ui:dialog.root>

<vite:asset entry="EXT:docs/Resources/Private/Components/ui/AnimationExamples/ProductCards.entry.ts" />
```

**ProductCards.entry.ts**

```ts
import { mount, mountAll } from 'fluid-primitives';
import { Dialog } from 'fluid-primitives/dialog';
import { animateLayout } from 'motion';

// Seconds, shared by the layout and the colour transition of the card.
const duration = 0.4;

mountAll('ui:animationExamples.productCards', ({ props, createHydrator }) => {
    const hydrator = createHydrator();
    const slot = hydrator.query('slot');
    const extras = hydrator.query('extras');
    const description = hydrator.query('description');
    const content = slot?.parentElement;
    const positioner = content?.parentElement;
    if (!slot || !extras || !description || !content || !positioner) return;

    let current: { button: HTMLElement; card: HTMLElement } | null = null;
    let landing: (() => void) | null = null;
    let timer: number | undefined;

    // The same elements move between the grid and the dialog, so the layout animation flies them over.
    const move = (update: () => void, done?: () => void) =>
        animateLayout(
            update,
            matchMedia('(prefers-reduced-motion: reduce)').matches
                ? { duration: 0 }
                : { type: 'spring', duration, bounce: 0 }
        ).then(animation => animation.finished.then(done));

    const open = () => {
        if (!current) return;
        const { button, card } = current;
        // A card that is still flying back lands in its list first, then this one lifts off.
        landing?.();

        move(() => {
            button.style.height = `${button.offsetHeight}px`;
            card.toggleAttribute('data-expanded', true);
            slot.append(card);
            card.append(extras);
            description.textContent = button.dataset.description ?? '';
            content.ariaLabel = card.querySelector('[data-title]')?.textContent ?? null;
        });
    };

    // The card stays in the dialog layer and flies to the place of its button there, so it never
    // passes below the backdrop. Only after it has landed it moves back into the list.
    const close = () => {
        if (!current) return;
        const { button, card } = current;
        const land = () => {
            Object.assign(card.style, { position: '', top: '', left: '', width: '' });
            button.append(card);
            button.style.height = '';
            if (landing === land) landing = null;
        };
        landing = land;

        move(
            () => {
                const { top, left, width } = button.getBoundingClientRect();
                card.toggleAttribute('data-expanded', false);
                slot.after(extras);
                positioner.append(card);
                Object.assign(card.style, {
                    position: 'fixed',
                    top: `${top}px`,
                    left: `${left}px`,
                    width: `${width}px`,
                });
            },
            // A newer move has taken over when `landing` is not this one any more.
            () => landing === land && land()
        );
    };

    mount('ui:dialog', `${props.id}-dialog`, ({ props }) =>
        new Dialog({
            ...props,
            // The dialog knows which of its triggers is active, so the card needs no click listener.
            onTriggerValueChange: ({ triggerElement: button }) => {
                const card = button?.querySelector<HTMLElement>('[data-card]');
                current = button && card ? { button, card } : null;
                card?.style.setProperty('--duration', `${duration}s`);
            },
            onOpenChange: ({ open: isOpen }) => {
                clearTimeout(timer);
                // The description and the button fade out first, then the card flies back.
                // Zag sets the active trigger right after this callback, so the card is known one microtask later.
                if (isOpen) queueMicrotask(open);
                else timer = window.setTimeout(close, 100);
            },
        }).init()
    );
});

```

### Animate a button's width and label

A width cannot transition between two `auto` values, so a button whose label changes length jumps. The three labels are in the markup and `data-status` decides which one is displayed. They enter and leave with the `@starting-style` and `allow-discrete` pair from above, and `justify-center` keeps them in the middle of the button while it resizes. Motion only animates the width: the script reads it before and after the status change and springs between the two.

**AnimationExamples.html**

```html
<f:variable name="label" value="
    absolute inset-x-0 hidden items-center justify-center gap-2 opacity-0
    transition-[opacity,translate,display] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] transition-discrete
    motion-safe:translate-y-6
" />

<ui:button
    autoMount="{false}"
    rootId="{rootId}-button"
    focusableWhenDisabled="{true}"
    data-status="idle"
    class="group/button relative overflow-hidden transition-[background-color,border-color,box-shadow]">
    <span {ui:ref(name: 'labels')} aria-live="polite" class="relative flex items-center justify-center">
        <span class="{label} group-data-[status=idle]/button:relative group-data-[status=idle]/button:flex group-data-[status=idle]/button:opacity-100 group-data-[status=idle]/button:translate-y-0 starting:group-data-[status=idle]/button:opacity-0 motion-safe:starting:group-data-[status=idle]/button:-translate-y-6">
            Send me a login link
        </span>
        <span class="{label} group-data-[status=loading]/button:relative group-data-[status=loading]/button:flex group-data-[status=loading]/button:opacity-100 group-data-[status=loading]/button:translate-y-0 starting:group-data-[status=loading]/button:opacity-0 motion-safe:starting:group-data-[status=loading]/button:-translate-y-6">
            <svg class="size-4 animate-spin" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
                <path d="M21 12a9 9 0 1 1-6.219-8.56" />
            </svg>
            Sending
        </span>
        <span class="{label} group-data-[status=success]/button:relative group-data-[status=success]/button:flex group-data-[status=success]/button:opacity-100 group-data-[status=success]/button:translate-y-0 starting:group-data-[status=success]/button:opacity-0 motion-safe:starting:group-data-[status=success]/button:-translate-y-6">
            Login link sent!
        </span>
    </span>
</ui:button>

<vite:asset entry="EXT:docs/Resources/Private/Components/ui/AnimationExamples/LoginLinkButton.entry.ts" />

```

**LoginLinkButton.entry.ts**

```ts
import { mount, mountAll } from 'fluid-primitives';
import { Button } from 'fluid-primitives/button';
import { animate } from 'motion';

type Status = 'idle' | 'loading' | 'success';

mountAll('ui:animationExamples.loginLinkButton', ({ props }) => {
    const button = mount('ui:button', `${props.id}-button`, ({ props }) =>
        new Button(props).init()
    );
    const root = button?.hydrator.query('root');
    if (!button || !root) return;

    // A width cannot transition between two `auto` values, so Motion animates it from the old to the new width.
    const show = (status: Status) => {
        const from = root.getBoundingClientRect().width;
        root.dataset.status = status;
        const to = root.getBoundingClientRect().width;
        if (from === to || matchMedia('(prefers-reduced-motion: reduce)').matches) return;

        // Pinned before the next paint, so no frame shows the new width early.
        root.style.width = `${from}px`;
        animate(0, 1, {
            type: 'spring',
            duration: 0.3,
            bounce: 0,
            onUpdate: progress => (root.style.width = `${from + (to - from) * progress}px`),
            onComplete: () => root.style.removeProperty('width'),
        });
    };

    let status: Status = 'idle';
    button.api.addEventListener('click', () => {
        if (status !== 'idle') return;

        status = 'loading';
        button.api.setLoading(true);
        show('loading');

        setTimeout(() => {
            status = 'success';
            button.api.setLoading(false);
            show('success');
        }, 1750);
        setTimeout(() => {
            status = 'idle';
            show('idle');
        }, 3500);
    });
});

```
