Native Solid 2 fork. Original documentation and designs © WorkOS; historical posts retain their original context.

Animation

Animate Radix Primitives with CSS keyframes or the JavaScript animation library of your choice.

Adding animation to Radix Primitives should feel similar to any other component, but there are some caveats noted here in regards to exiting animations with JS animation libraries.

Animating with CSS animation

The simplest way to animate Primitives is with CSS.

You can use CSS animation to animate both mount and unmount phases. The latter is possible because the Radix Primitives will suspend unmount while your animation plays out.

@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.DialogOverlay[data-state="open"],
.DialogContent[data-state="open"] {
animation: fadeIn 300ms ease-out;
}
.DialogOverlay[data-state="closed"],
.DialogContent[data-state="closed"] {
animation: fadeOut 300ms ease-in;
}

Delegating unmounting for JavaScript Animation

Hidden primitive parts normally leave the native Solid tree and the DOM. Use forceMount when JavaScript controls their exit animation, and remove the parts only after that animation finishes.

This example uses the browser's Web Animations API. Reopening cancels a pending exit; disposing the component cancels its frame and animations. CSS animation remains the simpler choice when you do not need JavaScript control.

import { createSignal, onCleanup } from "solid-js";
import { Dialog } from "@radix-solid/primitives";
export default function Example() {
const [open, setOpen] = createSignal(false);
const [present, setPresent] = createSignal(false);
let overlay!: HTMLDivElement;
let content!: HTMLDivElement;
let frame: number | undefined;
let animations: Animation[] = [];
let revision = 0;
const cancel = () => {
if (frame !== undefined) cancelAnimationFrame(frame);
frame = undefined;
for (const animation of animations) animation.cancel();
};
onCleanup(() => { revision++; cancel(); });
const changeOpen = (next: boolean) => {
const current = ++revision;
cancel();
setOpen(next);
if (next) setPresent(true);
frame = requestAnimationFrame(() => {
frame = undefined;
const opacity = next ? [0, 1] : [1, 0];
const duration = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 200;
animations = [
overlay.animate({ opacity }, { duration, fill: "forwards" }),
content.animate({ opacity }, { duration, fill: "forwards" })
];
Promise.all(animations.map(animation => animation.finished)).then(() => {
if (!next && revision === current) setPresent(false);
}, error => {
if (!(error instanceof DOMException && error.name === "AbortError")) throw error;
});
});
};
return <Dialog.Root open={open()} onOpenChange={changeOpen}>
<Dialog.Trigger>Open animated dialog</Dialog.Trigger>
<Dialog.Portal>
{present() && <>
<Dialog.Overlay forceMount ref={element => { overlay = element; }} style={{ position: "fixed", inset: "0", background: "rgb(0 0 0 / 0.4)", "pointer-events": open() ? "auto" : "none" }} />
<Dialog.Content forceMount ref={element => { content = element; }} inert={!open()} style={{ position: "fixed", left: "50%", top: "50%", transform: "translate(-50%, -50%)", padding: "24px", background: "var(--color-panel-solid)" }}>
<Dialog.Title>Animated dialog</Dialog.Title>
<Dialog.Description>The exit finishes before the parts are removed.</Dialog.Description>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Content>
</>}
</Dialog.Portal>
</Dialog.Root>;
}