Skip to content
llms.txt
llms.txt

CSS animation examples

Keep motion values in CSS custom properties so a design system can tune them without changing interaction code. transformOrigin() is optional: it is useful when flip() or shift() can change the final side, while a fixed placement can use a CSS-only origin.

import { flip, offset, shift, transformOrigin } from "@floating-ui-plus/web";
const middleware = [
offset(8),
flip(),
shift({ padding: 8 }),
transformOrigin({ padding: 8 }),
];
.popover {
--motion-distance: 0.35rem;
--motion-scale: 0.98;
--motion-duration-in: 160ms;
--motion-duration-out: 120ms;
--motion-ease: cubic-bezier(0.32, 0.72, 0, 1);
opacity: 0;
translate: 0 var(--motion-distance);
scale: var(--motion-scale);
transform-origin: var(--floating-transform-origin, 50% 0%);
transition:
opacity var(--motion-duration-out) var(--motion-ease),
translate var(--motion-duration-out) var(--motion-ease),
scale var(--motion-duration-out) var(--motion-ease),
display var(--motion-duration-out) allow-discrete,
overlay var(--motion-duration-out) allow-discrete;
}
.popover:popover-open {
opacity: 1;
translate: 0;
scale: 1;
transition-duration: var(--motion-duration-in);
}
@starting-style {
.popover:popover-open {
opacity: 0;
translate: 0 var(--motion-distance);
scale: var(--motion-scale);
}
}

The discrete display and overlay transitions keep a native Popover in the top layer until its visual exit finishes. Without those properties, the surface still closes correctly, but it closes immediately. See Entry and exit animation for the dialog variant and browser fallback details.

Native dialogs use :open. A Sheet disables transform positioning so CSS owns the physical edge and the travel direction:

.sheet {
position: fixed;
inset: auto 0 0;
width: 100%;
max-height: 90dvh;
margin: 0;
--sheet-enter-y: 100%;
--sheet-enter-x: 0;
--sheet-duration-in: 240ms;
--sheet-duration-out: 180ms;
opacity: 0;
translate: var(--sheet-enter-x) var(--sheet-enter-y);
transition:
opacity var(--sheet-duration-out) ease,
translate var(--sheet-duration-out) cubic-bezier(0.32, 0.72, 0, 1),
display var(--sheet-duration-out) allow-discrete,
overlay var(--sheet-duration-out) allow-discrete;
}
.sheet:open {
opacity: 1;
translate: 0;
transition-duration: var(--sheet-duration-in);
}
.sheet[data-side="top"] {
inset: 0 0 auto;
--sheet-enter-y: -100%;
}
.sheet[data-side="right"] {
inset: 0 0 0 auto;
width: min(25rem, 100vw);
--sheet-enter-y: 0;
--sheet-enter-x: 100%;
translate: var(--sheet-enter-x) 0;
}
@starting-style {
.sheet:open {
opacity: 0;
translate: var(--sheet-enter-x) var(--sheet-enter-y);
}
}
.sheet::backdrop {
background: rgb(15 23 42 / 0%);
transition:
background-color var(--sheet-duration-out) ease,
display var(--sheet-duration-out) allow-discrete,
overlay var(--sheet-duration-out) allow-discrete;
}
.sheet:open::backdrop {
background: rgb(15 23 42 / 32%);
}

Use the same contract for Web Components and Vue. Only the renderer differs; the CSS remains application-owned. The Sheet demo includes all four directions, backdrop dismissal, safe-area padding, and reduced-motion handling.

The demo Toast is a native manual Popover, so entry and exit follow the same :popover-open, @starting-style, and discrete display/overlay contract as other top-layer surfaces. Keep the node mounted with FloatingTransition or useFloatingTransition until that CSS finishes. Use --floating-presence-count for stack size; sibling-count() also sees a Web Component <template> sibling. sibling-index() can still derive the 0-based index when clones are chronological (newest last); --floating-presence-index is the fallback.

.toast-item {
--toast-index: var(--floating-presence-index, 0);
--toast-index: calc(sibling-count() - sibling-index());
--toast-count: var(--floating-presence-count, 1);
--toast-peek-index: min(var(--toast-index), 2);
position: fixed;
inset-inline-end: 0;
inset-block-end: 0;
width: min(22.5rem, calc(100vw - 2rem));
transform-origin: 100% 100%;
opacity: 0;
translate: 0 120%;
scale: 0.98;
transition:
opacity 180ms ease,
translate 220ms cubic-bezier(0.32, 0.72, 0, 1),
scale 220ms cubic-bezier(0.32, 0.72, 0, 1),
display 220ms allow-discrete,
overlay 220ms allow-discrete;
}
.toast-item:popover-open {
opacity: calc(1 - var(--toast-peek-index) * 0.14);
translate: 0 calc(var(--toast-peek-index) * -10px);
scale: calc(1 - var(--toast-peek-index) * 0.04);
}
.toast-viewport[data-presence-paused] .toast-item:popover-open {
translate: 0 calc(var(--toast-index) * var(--toast-row, 84px) * -1);
scale: 1;
opacity: 1;
}
@starting-style {
.toast-item:popover-open {
opacity: 0;
translate: 0 120%;
scale: 0.98;
}
}
.toast-item[data-status="initial"],
.toast-item[data-status="close"] {
opacity: 0;
translate: 0 120%;
scale: 0.98;
}
.toast-item[data-status="close"] {
pointer-events: none;
}

The package controls data-status; stack depth and pause layout remain application CSS. Size --floating-presence-hit-span to the current expanded stack and keep a fixed paused row so content stays readable. See the Toast demo for the complete pause, stack, and accessibility example.

If the placement never flips, expose a side attribute and choose the origin in CSS:

.popover[data-side="top"] { --motion-origin: 50% 100%; }
.popover[data-side="right"] { --motion-origin: 0% 50%; }
.popover[data-side="bottom"] { --motion-origin: 50% 0%; }
.popover[data-side="left"] { --motion-origin: 100% 50%; }
.popover { transform-origin: var(--floating-transform-origin, var(--motion-origin, 50% 0%)); }

CSS cannot detect a runtime flip or the exact shifted reference point. Use the middleware when those values affect the motion origin.

Toast and other non-native surfaces use FloatingTransition (or Vue’s useFloatingTransition) to keep the node mounted while the exit CSS runs. The transition status is an attribute your CSS can target; the package removes the node when the close state finishes. For motion-sensitive users, preserve the lifecycle but reduce travel and duration:

@media (prefers-reduced-motion: reduce) {
.popover,
.toast-item {
--motion-distance: 0;
--motion-scale: 1;
--motion-duration-in: 80ms;
--motion-duration-out: 80ms;
transition-duration: 80ms;
}
}